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.
This commit is contained in:
Brennan Benson
2026-10-04 00:40:43 -07:00
parent 628f65d04d
commit 9279746b16
6 changed files with 188 additions and 83 deletions
@@ -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']])
})
})
@@ -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<StructuredLaunchAttempt, { kind: 'first' }> | 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)
)
)
}
@@ -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<StructuredLaunchState
return structuredLaunchesBySessionId.values()
}
function launchStateLifecycle(state: StructuredLaunchState): StructuredAgentSessionLaunchLifecycle {
export function launchStateLifecycle(
state: StructuredLaunchState
): StructuredAgentSessionLaunchLifecycle {
if (state.cancelled || state.callers.outcome === 'cancelled') {
return 'cancelled'
}
@@ -1,14 +1,14 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { structuredLaunchesHoldingIdentity } from './structured-agent-session-launch-holders'
import {
structuredLaunchesHoldingIdentity,
structuredLaunchIdentity,
subscribeStructuredAgentLaunchStatus,
type StructuredAgentLaunchStatus
} from './structured-agent-session-launch-registry'
import type { StructuredLaunchRequest } from './structured-agent-session-launch-request'
/** With `request`, only the launches a start of it would join: a different request opens its own. */
/** With `request`, only launches a start of it would repeat: any other request is new work. */
export function getStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider,
@@ -474,17 +474,15 @@ describe('startStructuredAgentLaunch', () => {
const worktreeId = 'wt-coalesced-prompt-reservation'
const intent = launchIntent(worktreeId)
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
let resolveDelivery!: (result: {
ok: true
value: { submission: { dispatchState: 'accepted' } }
}) => void
const pendingSends: ((result: unknown) => void)[] = []
mocks.createIntent.mockReturnValue(intent)
mocks.launch.mockImplementationOnce(() => new Promise((resolve) => (resolveLaunch = resolve)))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
mocks.callStructuredAgentSession.mockImplementationOnce(
() => new Promise((resolve) => (resolveDelivery = resolve))
// Every send waits, so a second send of the repeated text would show up below.
mocks.callStructuredAgentSession.mockImplementation(
() => new Promise((resolve) => pendingSends.push(resolve))
)
startStructuredAgentLaunch(worktreeId, 'codex', { 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 () => {
@@ -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 {