diff --git a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts index e466f779f77..7dee1159dd3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts @@ -58,8 +58,23 @@ vi.mock('@/lib/agent-catalog', () => ({ })) import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' +import { + appendStructuredAgentSessionOutboxMessage, + readOutbox +} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache, + writeNativeChatDraftCache +} from '@/components/native-chat/native-chat-draft-cache' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../../shared/structured-agent-session-projection' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { getStructuredAgentLaunchStatus, + getStructuredAgentSessionLaunchLifecycle, startStructuredAgentLaunch } from './structured-agent-session-launch' import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' @@ -289,6 +304,7 @@ describe('an empty chat still starting', () => { .mockReturnValueOnce(first) .mockReturnValueOnce(second) .mockReturnValueOnce(third) + clearNativeChatDraftCacheForTests() mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) => intent.sessionId === first.sessionId ? new Promise((resolve) => (resolveFirstLaunch = resolve)) @@ -370,4 +386,65 @@ describe('an empty chat still starting', () => { } expect(sends()).toEqual([[first.sessionId, 'review notes']]) }) + + it('leaves a chat its user already sent into to them, and opens a new chat for the notes', async () => { + const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex') + appendStructuredAgentSessionOutboxMessage(blank.sessionId, 'my own question') + + const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest) + + expect(notes.sessionId).toBe(second.sessionId) + await expect(notes.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(sends()).toEqual([[second.sessionId, 'review notes']]) + expect(readOutbox(blank.sessionId).map((entry) => entry.body.blocks)).toEqual([ + [{ type: 'text', text: 'my own question' }] + ]) + }) + + it('leaves a chat its user is typing into to them, and opens a new chat for the notes', async () => { + const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex') + const paneKey = structuredAgentSessionPaneKey( + structuredAgentSessionTabId(blank.sessionId), + blank.sessionId + ) + writeNativeChatDraftCache(paneKey, 'half a question') + + const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest) + + expect(notes.sessionId).toBe(second.sessionId) + await expect(notes.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(readOutbox(blank.sessionId)).toEqual([]) + expect(readNativeChatDraftCache(paneKey)).toBe('half a question') + }) + + it('opens a new chat that shows the failure when the claiming text cannot be saved', async () => { + const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex') + const storageFailure = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new Error('storage unavailable') + }) + const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest) + storageFailure.mockRestore() + + // The new chat fails with its Retry line, the failure the caller is told was shown. + expect(notes.sessionId).toBe(second.sessionId) + await expect(notes.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + await expect(notes.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, second.sessionId)).toBe('failed') + // No claim was recorded: the blank chat is still blank and still claimable. + expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex').sessionId).toBe(blank.sessionId) + expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest).sessionId).toBe( + blank.sessionId + ) + }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts b/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts new file mode 100644 index 00000000000..e8c5becd685 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts @@ -0,0 +1,16 @@ +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../../shared/structured-agent-session-projection' +import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { readNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache' + +/** A starting chat is empty until its user sends into it or types in its composer; after that it + * is theirs, and another request's text never goes into it. */ +export function isStructuredLaunchChatEmpty(sessionId: string): boolean { + const paneKey = structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) + return ( + getStructuredAgentSessionOutbox(sessionId).length === 0 && + readNativeChatDraftCache(paneKey).trim() === '' + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-holders.ts b/src/renderer/src/lib/structured-agent-session-launch-holders.ts index c2adc74842a..68b0c1728f6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-holders.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-holders.ts @@ -8,6 +8,7 @@ import { type StructuredLaunchAttempt, type StructuredLaunchRequest } from './structured-agent-session-launch-request' +import { isStructuredLaunchChatEmpty } from './structured-agent-session-launch-empty-chat' // Why: coalescing stops a repeat of one request (a double click) racing into two chats. A different // request, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new start @@ -39,7 +40,8 @@ export function structuredLaunchesHoldingIdentity( } /** An empty chat (a + pick, the empty-workspace default) still starting: the first request with text - * claims it once, and from then on it is that request's chat. A resume is never empty. */ + * claims it once, and from then on it is that request's chat. A resume is never empty, nor a chat + * its user has already sent or typed into. */ export function claimableStructuredLaunchAttempt( state: StructuredLaunchState, request: StructuredLaunchRequest @@ -48,7 +50,8 @@ export function claimableStructuredLaunchAttempt( return !state.intent.params.resumeFrom && attempt.kind === 'first' && attempt.request.text === '' && - request.text !== '' + request.text !== '' && + isStructuredLaunchChatEmpty(state.intent.sessionId) ? attempt : undefined } diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 04653b5e70e..d2a174110c6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -45,7 +45,8 @@ import { applyStructuredLaunchHeldOptions } from './structured-agent-session-lau import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' import { repeatedStructuredLaunchAttempt, - structuredLaunchRequest + structuredLaunchRequest, + type StructuredLaunchRequest } from './structured-agent-session-launch-request' export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt } @@ -155,6 +156,54 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { notifyStructuredLaunchListeners() } +function joinStructuredLaunchState( + existing: StructuredLaunchState, + agent: AgentSessionHandleProvider, + options: StructuredAgentLaunchOptions, + request: StructuredLaunchRequest +): StructuredLaunchStateResult | undefined { + // A repeat (a double click) shares the text the first click staged, so it is sent once. + const repeat = repeatedStructuredLaunchAttempt(existing.callers.attempt, request) + // An empty chat takes the first text sent to it, delivered the way that request asked. + const claim = claimableStructuredLaunchAttempt(existing, request) + const retrying = existing.visibilityUnknown + const joined = joinLaunchDelivery( + options, + claim ? options.promptDelivery : existing.promptDelivery + ) + // Why: an unconfirmed launch keeps its draft/outbox, so a recheck must not stage it twice. + const text = retrying || repeat ? '' : outboxPromptText(joined) + const stagedPrompt = text + ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text) + : (repeat?.stagedEntry ?? null) + // An unstaged claim stays unclaimed: the new launch it falls to reports the failure. + if (claim && text && !stagedPrompt) { + return undefined + } + if (retrying) { + restartStructuredLaunchState(existing) + } + if (claim) { + existing.promptDelivery = options.promptDelivery + Object.assign(claim, { request, stagedEntry: stagedPrompt }) + } + if (!retrying && !repeat) { + launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined) + } + const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined + const callerOptions = retrying ? joinedWithoutPrompt : joined + return { + state: existing, + caller: addStructuredLaunchCaller({ + group: existing.callers, + launchResult: existing.promise, + target: existing.intent.target, + options: callerOptions, + stagedEntry: stagedPrompt + }) + } +} + function structuredAgentLaunchState( worktreeId: string, agent: AgentSessionHandleProvider, @@ -163,42 +212,9 @@ function structuredAgentLaunchState( const identity = structuredLaunchIdentity(worktreeId, agent, options.resumeFrom) const request = structuredLaunchRequest(options) const existing = getJoinableStructuredLaunchState(identity, request) - if (existing) { - // A repeat (a double click) shares the text the first click staged, so it is sent once. - const repeat = repeatedStructuredLaunchAttempt(existing.callers.attempt, request) - // An empty chat takes the first text sent to it, delivered the way that request asked. - const claim = claimableStructuredLaunchAttempt(existing, request) - if (claim) { - existing.promptDelivery = options.promptDelivery - } - const retrying = existing.visibilityUnknown - if (retrying) { - restartStructuredLaunchState(existing) - } - const joined = joinLaunchDelivery(options, existing.promptDelivery) - // Why: an unconfirmed launch keeps its draft/outbox, so a recheck must not stage it twice. - const text = retrying || repeat ? '' : outboxPromptText(joined) - const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text) - : (repeat?.stagedEntry ?? null) - if (!retrying && !repeat) { - launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined) - } - if (claim) { - Object.assign(claim, { request, stagedEntry: stagedPrompt }) - } - const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined - const callerOptions = retrying ? joinedWithoutPrompt : joined - return { - state: existing, - caller: addStructuredLaunchCaller({ - group: existing.callers, - launchResult: existing.promise, - target: existing.intent.target, - options: callerOptions, - stagedEntry: stagedPrompt - }) - } + const joined = existing && joinStructuredLaunchState(existing, agent, options, request) + if (joined) { + return joined } const intent = createStructuredAgentSessionLaunchIntent(