mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user