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