From 9279746b167f3b7eb87a6f4e09074ba9cf5bd41e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:40:43 -0700 Subject: [PATCH] fix(native-chat): an empty chat still starting takes the first text sent to it A blank chat that is still starting (a + pick, the empty-workspace default chat) is empty, so the first request with text, such as notes sent to a new agent, now goes into it instead of opening a second chat beside it. That request becomes the chat's own request: an identical repeat joins and is sent once, and any other request opens a new chat. Move the logic that decides which launch a start joins out of the launch registry into its own module, so the registry stays under the line limit once #24904 lands beside it. Pin that a repeat arriving while the opening text is still sending is sent once. --- ...t-session-launch-different-request.test.ts | 111 +++++++++++++----- ...structured-agent-session-launch-holders.ts | 69 +++++++++++ ...tructured-agent-session-launch-registry.ts | 44 +------ .../structured-agent-session-launch-status.ts | 4 +- .../structured-agent-session-launch.test.ts | 30 +++-- .../lib/structured-agent-session-launch.ts | 13 +- 6 files changed, 188 insertions(+), 83 deletions(-) create mode 100644 src/renderer/src/lib/structured-agent-session-launch-holders.ts 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 7a7ae7b3d3a..153908aa0bd 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 @@ -166,33 +166,6 @@ describe('a different new request while the first chat is still starting', () => ]) }) - it('sends notes to a new chat while a blank chat from the + menu is starting', async () => { - const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex') - // The notes menu stays enabled: its pick would not join the blank chat. - expect( - getStructuredAgentLaunchStatus( - WORKTREE_ID, - 'codex', - structuredLaunchRequest({ prompt: 'review notes' }) - ) - ).toBe('idle') - expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex', structuredLaunchRequest({}))).toBe( - 'pending' - ) - - const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { - prompt: 'review notes', - promptDelivery: 'submit-after-ready' - }) - - expect(notes.sessionId).not.toBe(blank.sessionId) - await expect(notes.promptDeliveryResult).resolves.toEqual({ - delivered: true, - failureNotified: false - }) - expect(sends()).toEqual([[second.sessionId, 'review notes']]) - }) - it('opens a new chat with its own text while the first chat is still sending its text', async () => { let resolveFirstSend!: (result: unknown) => void mocks.callStructuredAgentSession.mockImplementationOnce( @@ -301,3 +274,87 @@ describe('the same request repeated while the first chat is still starting', () expect(mocks.seedDraft).toHaveBeenCalledOnce() }) }) + +describe('an empty chat still starting', () => { + let resolveFirstLaunch!: (receipt: { sessionId: string; fence: number }) => void + const third = launchIntent('session-third') + const notesRequest = { prompt: 'review notes', promptDelivery: 'submit-after-ready' } as const + + beforeEach(() => { + vi.resetAllMocks() + localStorage.clear() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentLaunchRegistryForTests() + mocks.createIntent + .mockReturnValueOnce(first) + .mockReturnValueOnce(second) + .mockReturnValueOnce(third) + mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) => + intent.sessionId === first.sessionId + ? new Promise((resolve) => (resolveFirstLaunch = resolve)) + : Promise.resolve({ sessionId: intent.sessionId, fence: 1 }) + ) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(first.sessionId, second.sessionId, third.sessionId) + ]) + mocks.callStructuredAgentSession.mockResolvedValue({ + ok: true, + value: { submission: { dispatchState: 'accepted' } } + }) + }) + + it('takes notes sent to a new agent instead of opening a second chat', async () => { + const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex') + // The notes menu stays enabled: its pick fills the empty chat rather than repeating a start. + expect( + getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex', structuredLaunchRequest(notesRequest)) + ).toBe('idle') + + const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest) + resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 }) + + expect(notes.sessionId).toBe(blank.sessionId) + expect(mocks.createIntent).toHaveBeenCalledOnce() + await expect(notes.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(sends()).toEqual([[first.sessionId, 'review notes']]) + }) + + it('opens a new chat for any other request once its notes claimed it', async () => { + startStructuredAgentLaunch(WORKTREE_ID, 'codex') + startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest) + + const fix = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { + prompt: 'Fix check B', + promptDelivery: 'submit-after-ready' + }) + const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex') + + expect(fix.sessionId).toBe(second.sessionId) + expect(pick.sessionId).toBe(third.sessionId) + await expect(fix.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(sends()).toEqual([[second.sessionId, 'Fix check B']]) + }) + + it('sends the same notes once when they are sent again', async () => { + const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex') + const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest) + const again = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest) + resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 }) + + expect(again.sessionId).toBe(blank.sessionId) + expect(mocks.createIntent).toHaveBeenCalledOnce() + for (const caller of [notes, again]) { + await expect(caller.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + } + expect(sends()).toEqual([[first.sessionId, 'review notes']]) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-holders.ts b/src/renderer/src/lib/structured-agent-session-launch-holders.ts new file mode 100644 index 00000000000..c2adc74842a --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-holders.ts @@ -0,0 +1,69 @@ +import { + launchStateLifecycle, + structuredLaunchStates, + type StructuredLaunchState +} from './structured-agent-session-launch-registry' +import { + joinsFirstLaunchAttempt, + type StructuredLaunchAttempt, + type StructuredLaunchRequest +} from './structured-agent-session-launch-request' + +// Why: coalescing stops a repeat of one request (a double click) racing into two chats. A different +// request, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new start +// opens a new chat carrying its own text. A resume keeps holding: the host refuses a second adoption. +function holdsLaunchIdentity( + state: StructuredLaunchState, + request?: StructuredLaunchRequest +): boolean { + const lifecycle = launchStateLifecycle(state) + if (lifecycle === 'failed' || lifecycle === 'cancelled') { + return false + } + if (state.intent.params.resumeFrom) { + return true + } + return ( + lifecycle !== 'visibility-unknown' && joinsFirstLaunchAttempt(state.callers.attempt, request) + ) +} + +/** Launches a start of `request` would repeat; without `request`, every new start's own create. */ +export function structuredLaunchesHoldingIdentity( + matches: (identity: string) => boolean, + request?: StructuredLaunchRequest +): StructuredLaunchState[] { + return [...structuredLaunchStates()].filter( + (state) => matches(state.identity) && holdsLaunchIdentity(state, request) + ) +} + +/** An empty chat (a + pick, the empty-workspace default) still starting: the first request with text + * claims it once, and from then on it is that request's chat. A resume is never empty. */ +export function claimableStructuredLaunchAttempt( + state: StructuredLaunchState, + request: StructuredLaunchRequest +): Extract | undefined { + const { attempt } = state.callers + return !state.intent.params.resumeFrom && + attempt.kind === 'first' && + attempt.request.text === '' && + request.text !== '' + ? attempt + : undefined +} + +/** The launch a new start of `request` joins: one it repeats, else an empty chat it claims. The + * newest wins if a retried resume holds the identity too. */ +export function getJoinableStructuredLaunchState( + identity: string, + request: StructuredLaunchRequest +): StructuredLaunchState | undefined { + const matches = (candidate: string): boolean => candidate === identity + return ( + structuredLaunchesHoldingIdentity(matches, request).at(-1) ?? + structuredLaunchesHoldingIdentity(matches).findLast((state) => + claimableStructuredLaunchAttempt(state, request) + ) + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 8c419ba4ce7..9d21852b2fb 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -9,10 +9,6 @@ import type { StructuredAgentLaunchOptions, StructuredLaunchCallerGroup } from './structured-agent-session-launch-callers' -import { - joinsFirstLaunchAttempt, - type StructuredLaunchRequest -} from './structured-agent-session-launch-request' import { deleteStructuredAgentLaunchRecord, hasStructuredAgentLaunchCancellationTombstonePersisted, @@ -82,42 +78,6 @@ export function structuredLaunchIdentity( : `${agent}:${worktreeId}` } -// Why: coalescing stops a repeat of one request (a double click) racing into two chats. A different -// request, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new start -// opens a new chat carrying its own text. A resume keeps holding: the host refuses a second adoption. -function holdsLaunchIdentity( - state: StructuredLaunchState, - request?: StructuredLaunchRequest -): boolean { - const lifecycle = launchStateLifecycle(state) - if (lifecycle === 'failed' || lifecycle === 'cancelled') { - return false - } - if (state.intent.params.resumeFrom) { - return true - } - return ( - lifecycle !== 'visibility-unknown' && joinsFirstLaunchAttempt(state.callers.attempt, request) - ) -} - -export function structuredLaunchesHoldingIdentity( - matches: (identity: string) => boolean, - request?: StructuredLaunchRequest -): StructuredLaunchState[] { - return [...structuredLaunchesBySessionId.values()].filter( - (state) => matches(state.identity) && holdsLaunchIdentity(state, request) - ) -} - -/** The launch a new start of `request` joins; the newest wins if a retried resume holds it too. */ -export function getJoinableStructuredLaunchState( - identity: string, - request: StructuredLaunchRequest -): StructuredLaunchState | undefined { - return structuredLaunchesHoldingIdentity((candidate) => candidate === identity, request).at(-1) -} - export function getStructuredLaunchStateBySessionId( sessionId: string ): StructuredLaunchState | undefined { @@ -157,7 +117,9 @@ export function structuredLaunchStates(): IterableIterator { const worktreeId = 'wt-coalesced-prompt-reservation' const intent = launchIntent(worktreeId) let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void - let resolveDelivery!: (result: { - ok: true - value: { submission: { dispatchState: 'accepted' } } - }) => void + const pendingSends: ((result: unknown) => void)[] = [] mocks.createIntent.mockReturnValue(intent) mocks.launch.mockImplementationOnce(() => new Promise((resolve) => (resolveLaunch = resolve))) vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ publishedSnapshot(worktreeId, intent.sessionId) ]) - mocks.callStructuredAgentSession.mockImplementationOnce( - () => new Promise((resolve) => (resolveDelivery = resolve)) + // Every send waits, so a second send of the repeated text would show up below. + mocks.callStructuredAgentSession.mockImplementation( + () => new Promise((resolve) => pendingSends.push(resolve)) ) startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' }) @@ -492,15 +490,23 @@ describe('startStructuredAgentLaunch', () => { resolveLaunch({ sessionId: intent.sessionId, fence: 1 }) await vi.waitFor(() => expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce()) - startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' }) + const whileSending = startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: 'second prompt' + }) expect(mocks.createIntent).toHaveBeenCalledOnce() expect(mocks.launch).toHaveBeenCalledOnce() - resolveDelivery({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) - await expect(coalesced.promptDeliveryResult).resolves.toEqual({ - delivered: true, - failureNotified: false - }) + await flushLaunchSettlement() + for (const resolve of pendingSends) { + resolve({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + } + for (const caller of [coalesced, whileSending]) { + await expect(caller.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + } + expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce() }) it('opens a new chat for a new start while an earlier outcome is unknown', async () => { diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 52fdf8e70b4..04653b5e70e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -28,7 +28,6 @@ import { import * as launchDraft from './structured-agent-session-launch-draft' import { deleteStructuredLaunchStateIfCurrent, - getJoinableStructuredLaunchState, getStructuredAgentSessionLaunchLifecycle, getStructuredLaunchStateBySessionId, markStructuredAgentSessionLaunchCancelled, @@ -38,6 +37,10 @@ import { type StructuredLaunchState } from './structured-agent-session-launch-registry' import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' +import { + claimableStructuredLaunchAttempt, + getJoinableStructuredLaunchState +} from './structured-agent-session-launch-holders' import { applyStructuredLaunchHeldOptions } from './structured-agent-session-launch-options' import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' import { @@ -163,6 +166,11 @@ function structuredAgentLaunchState( 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) @@ -176,6 +184,9 @@ function structuredAgentLaunchState( 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 {