fix(native-chat): only a chat its user has not used yet takes another request's text

A blank chat that is still starting was claimable by the first request with
text even after its user had sent a message into it or typed into its
composer, so notes or a Fix with AI prompt could land in a conversation the
user had already started. Emptiness is now read from what the chat holds:
nothing in its outbox and no text in its composer draft. A chat its user has
used stays theirs, and the request opens a new chat.

If the claiming text cannot be saved, the claim is not recorded: the request
falls through to a new launch, whose save failure shows on that chat as for
any new launch, instead of reporting a failure nothing showed.
This commit is contained in:
Brennan Benson
2026-10-04 00:55:35 -07:00
parent b04f371b35
commit 4b72d035de
4 changed files with 151 additions and 39 deletions
@@ -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
)
})
})
@@ -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() === ''
)
}
@@ -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
}
@@ -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(