mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
fix(native-chat): keep successful launches usable when tab lists lag (#33620)
* fix(native-chat): confirm launches from successful create replies * test(native-chat): align launch guards with successful create replies
This commit is contained in:
+2
-15
@@ -107,9 +107,6 @@ async function retrySameIntent(
|
||||
() => state.cancelled
|
||||
)
|
||||
throwIfLaunchCancelled(state)
|
||||
if (state.intent.createMessageSupport !== true) {
|
||||
await verifyPublishedSession(state)
|
||||
}
|
||||
return receipt
|
||||
} catch (error) {
|
||||
if (state.cancelled) {
|
||||
@@ -156,18 +153,8 @@ export async function launchAndReconcile(
|
||||
return retrySameIntent(state, error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
throwIfLaunchCancelled(state)
|
||||
if (state.intent.createMessageSupport !== true) {
|
||||
await verifyPublishedSession(state)
|
||||
}
|
||||
return receipt
|
||||
} catch (error) {
|
||||
if (state.cancelled) {
|
||||
throw new StructuredAgentSessionLaunchCancelledError()
|
||||
}
|
||||
return retrySameIntent(state, error)
|
||||
}
|
||||
throwIfLaunchCancelled(state)
|
||||
return receipt
|
||||
}
|
||||
|
||||
export async function reconcileUnknownLaunch(
|
||||
|
||||
@@ -215,7 +215,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
// No initial chat mode proves availability rejection preceded terminal-tab adoption.
|
||||
describe('structured chat adoption guard on the launch path', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
store.unifiedTabsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
@@ -277,7 +277,6 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
}
|
||||
])
|
||||
mockCreateSupport.mockResolvedValue({ supported: true })
|
||||
mockToastError.mockReset()
|
||||
hostCapabilities = STRUCTURED_HOST_CAPABILITIES
|
||||
store.settings.experimentalNativeChat = true
|
||||
store.settings.nativeChatSessionOptions = undefined
|
||||
@@ -542,41 +541,29 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(1)
|
||||
resolveLaunch({ sessionId: 'codex-session-1', fence: 1 })
|
||||
await Promise.all([first?.structuredSettlement, second?.structuredSettlement])
|
||||
})
|
||||
|
||||
it('keeps the single-flight reservation until the published tab inventory is refreshed', async () => {
|
||||
it('settles a successful create and opens its chat without an inventory refresh', async () => {
|
||||
store.unifiedTabsByWorktree = {}
|
||||
let resolveRefresh!: (snapshots: unknown[]) => void
|
||||
mockRefreshLocalStructuredSessionTabs.mockImplementationOnce(
|
||||
() => new Promise<unknown[]>((resolve) => (resolveRefresh = resolve))
|
||||
)
|
||||
mockLaunchStructuredCodexSession.mockResolvedValue({
|
||||
sessionId: 'codex-session-1',
|
||||
fence: 1
|
||||
})
|
||||
mockRefreshLocalStructuredSessionTabs.mockRejectedValue(new Error('inventory unavailable'))
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({ requestId: 'double-click', agent: 'codex', worktreeId: 'wt-1' })
|
||||
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(1))
|
||||
const result = launchAgentInNewTab({
|
||||
requestId: 'double-click',
|
||||
agent: 'codex',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
|
||||
launchAgentInNewTab({ requestId: 'double-click', agent: 'codex', worktreeId: 'wt-1' })
|
||||
await vi.waitFor(() => expect(mockCreateSupport).toHaveBeenCalledTimes(2))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
|
||||
store.unifiedTabsByWorktree['wt-1'] = [
|
||||
{
|
||||
id: 'structured-agent-session-codex-session-1',
|
||||
contentType: 'agent-session',
|
||||
entityId: 'codex-session-1',
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1'
|
||||
}
|
||||
]
|
||||
resolveRefresh([
|
||||
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] }
|
||||
])
|
||||
await vi.waitFor(() => expect(mockToastError).not.toHaveBeenCalled())
|
||||
await expect(result?.structuredSettlement).resolves.toEqual({
|
||||
kind: 'structured',
|
||||
sessionId: 'codex-session-1'
|
||||
})
|
||||
expect(store.unifiedTabsByWorktree['wt-1']).toContainEqual(
|
||||
expect.objectContaining({ contentType: 'agent-session', entityId: 'codex-session-1' })
|
||||
)
|
||||
expect(mockRefreshLocalStructuredSessionTabs).not.toHaveBeenCalled()
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-checks a chat whose visibility proof is unknown through its own Retry, with its own intent', async () => {
|
||||
@@ -587,7 +574,7 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
.mockReturnValueOnce(firstIntent)
|
||||
.mockReturnValueOnce(secondIntent)
|
||||
mockLaunchStructuredCodexSession
|
||||
.mockResolvedValueOnce({ sessionId: firstIntent.sessionId, fence: 1 })
|
||||
.mockRejectedValueOnce(new Error('response lost'))
|
||||
.mockRejectedValueOnce(new Error('response lost'))
|
||||
.mockResolvedValueOnce({ sessionId: secondIntent.sessionId, fence: 1 })
|
||||
mockRefreshLocalStructuredSessionTabs
|
||||
@@ -607,9 +594,6 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: firstIntent.sessionId }] }
|
||||
])
|
||||
})
|
||||
.mockResolvedValueOnce([
|
||||
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-2' }] }
|
||||
])
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
const unknown = launchAgentInNewTab({
|
||||
@@ -638,22 +622,31 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(2)
|
||||
expect(mockLaunchStructuredCodexSession.mock.calls[0]?.[0]).toBe(firstIntent)
|
||||
expect(mockLaunchStructuredCodexSession.mock.calls[1]?.[0]).toBe(firstIntent)
|
||||
expect(mockLaunchStructuredCodexSession.mock.calls[0]?.[2]).toBe(false)
|
||||
expect(mockLaunchStructuredCodexSession.mock.calls[1]?.[2]).toBe(true)
|
||||
expect(mockRetryStructuredAgentSessionLaunchIntent).not.toHaveBeenCalled()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(mockCallStructuredAgentSession).toHaveBeenCalledWith(
|
||||
firstIntent.target,
|
||||
'agentSession.history',
|
||||
expect.objectContaining({ sessionId: firstIntent.sessionId })
|
||||
)
|
||||
|
||||
store.unifiedTabsByWorktree['wt-1'] = [
|
||||
{
|
||||
id: `structured-agent-session-${secondIntent.sessionId}`,
|
||||
contentType: 'agent-session',
|
||||
entityId: secondIntent.sessionId,
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1'
|
||||
}
|
||||
]
|
||||
launchAgentInNewTab({ requestId: 'request-15', agent: 'codex', worktreeId: 'wt-1' })
|
||||
await vi.waitFor(() => expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3))
|
||||
const next = launchAgentInNewTab({
|
||||
requestId: 'request-15',
|
||||
agent: 'codex',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
await expect(next?.structuredSettlement).resolves.toEqual({
|
||||
kind: 'structured',
|
||||
sessionId: secondIntent.sessionId
|
||||
})
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(2)
|
||||
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3)
|
||||
expect(mockLaunchStructuredCodexSession.mock.calls[2]?.[0]).toBe(secondIntent)
|
||||
expect(store.unifiedTabsByWorktree['wt-1']?.map((tab) => tab.entityId)).toEqual(
|
||||
expect.arrayContaining([firstIntent.sessionId, secondIntent.sessionId])
|
||||
)
|
||||
})
|
||||
|
||||
it('routes an auto-submitted Codex prompt through the structured outbox', async () => {
|
||||
|
||||
@@ -183,12 +183,12 @@ describe('a close that races a structured launch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels a close-racing launch without retrying or toasting', async () => {
|
||||
it('cancels a close-racing unknown launch without retrying or toasting', async () => {
|
||||
const worktreeId = 'wt-close-race'
|
||||
const intent = launchIntent(worktreeId, 'session-close-race')
|
||||
let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
|
||||
mocks.launch.mockRejectedValueOnce(new Error('response lost'))
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveRefresh = resolve))
|
||||
)
|
||||
@@ -234,7 +234,7 @@ describe('a close that races a structured launch', () => {
|
||||
const intent = launchIntent(worktreeId)
|
||||
let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
|
||||
mocks.launch.mockRejectedValueOnce(new Error('response lost'))
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveRefresh = resolve))
|
||||
)
|
||||
|
||||
@@ -316,21 +316,19 @@ describe('startStructuredAgentLaunch', () => {
|
||||
it('clears the draft seed when the launch is cancelled', async () => {
|
||||
const worktreeId = 'wt-draft-cancelled'
|
||||
const intent = launchIntent(worktreeId, 'cancelled-draft-session')
|
||||
let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void
|
||||
const create = Promise.withResolvers<{ sessionId: string; fence: number }>()
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveRefresh = resolve))
|
||||
)
|
||||
mocks.launch.mockReturnValueOnce(create.promise)
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', {
|
||||
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
|
||||
requestId: 'request-5',
|
||||
prompt: 'review this',
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
|
||||
expect(mocks.launch).toHaveBeenCalledOnce()
|
||||
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
|
||||
resolveRefresh([])
|
||||
create.resolve({ sessionId: intent.sessionId, fence: 1 })
|
||||
await expect(launch.launchResult).rejects.toThrow('structured session launch cancelled')
|
||||
await flushLaunchSettlement()
|
||||
|
||||
expect(mocks.clearDraft).toHaveBeenCalledWith(
|
||||
@@ -427,32 +425,38 @@ describe('startStructuredAgentLaunch', () => {
|
||||
expect(toast.message).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires authoritative inventory even when a matching local tab exists', async () => {
|
||||
const worktreeId = 'wt-host-frame'
|
||||
const intent = launchIntent(worktreeId, 'session-host-frame')
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch.mockImplementationOnce(async () => {
|
||||
mocks.rendererTabs[worktreeId] = [
|
||||
{ contentType: 'agent-session', entityId: intent.sessionId, worktreeId }
|
||||
]
|
||||
for (const listener of mocks.listeners) {
|
||||
listener({ unifiedTabsByWorktree: mocks.rendererTabs })
|
||||
}
|
||||
return { sessionId: intent.sessionId, fence: 1 }
|
||||
})
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(worktreeId, intent.sessionId)
|
||||
])
|
||||
it.each(['late', 'missing', 'unavailable'] as const)(
|
||||
'uses an older host’s receipt to send while tab inventory is %s',
|
||||
async (inventory) => {
|
||||
const worktreeId = 'wt-host-frame'
|
||||
const intent = launchIntent(worktreeId, 'session-host-frame')
|
||||
intent.createMessageSupport = false
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
|
||||
let reads = 0
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockImplementation(async () => {
|
||||
reads += 1
|
||||
if (inventory === 'unavailable') {
|
||||
throw new Error('inventory unavailable')
|
||||
}
|
||||
return inventory === 'late' && reads > 1
|
||||
? [publishedSnapshot(worktreeId, intent.sessionId)]
|
||||
: []
|
||||
})
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-10' })
|
||||
await flushLaunchSettlement()
|
||||
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
|
||||
requestId: 'request-10',
|
||||
prompt: 'first task',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true })
|
||||
|
||||
expect(mocks.launch).toHaveBeenCalledOnce()
|
||||
expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledWith(undefined, {
|
||||
authoritative: true
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(mocks.launch).toHaveBeenCalledOnce()
|
||||
expect(refreshLocalStructuredSessionTabs).not.toHaveBeenCalled()
|
||||
expect(sends()).toHaveLength(1)
|
||||
expect(sends()[0]?.[2]).toMatchObject({ envelope: { sessionId: intent.sessionId } })
|
||||
}
|
||||
)
|
||||
|
||||
it('coalesces a duplicate click silently while the launch is in flight', async () => {
|
||||
const worktreeId = 'wt-duplicate-click'
|
||||
@@ -706,9 +710,10 @@ describe('startStructuredAgentLaunch', () => {
|
||||
expect(sends()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps a post-attach unknown outcome reserved for reconciliation', async () => {
|
||||
it('repairs failed tab publication on Retry with the original chat and operation', async () => {
|
||||
const worktreeId = 'wt-post-attach-unknown'
|
||||
const intent = launchIntent(worktreeId)
|
||||
intent.createMessageSupport = false
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch.mockRejectedValue(
|
||||
Object.assign(new Error('The chat may already exist.'), {
|
||||
@@ -724,8 +729,17 @@ describe('startStructuredAgentLaunch', () => {
|
||||
})
|
||||
expect(launch.isVisibilityUnknown()).toBe(true)
|
||||
expect(launch.releaseCallerAfterUnknownOutcome()).toBe(true)
|
||||
|
||||
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
|
||||
expect(retryStructuredAgentSessionLaunch(worktreeId, intent.sessionId)).toBe(true)
|
||||
await flushLaunchSettlement()
|
||||
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBeNull()
|
||||
expect(mocks.launch).toHaveBeenCalledTimes(3)
|
||||
for (const [retried] of mocks.launch.mock.calls) {
|
||||
expect(retried).toBe(intent)
|
||||
}
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
expect(mocks.launch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries a definitively refused launch with the same session and a new operation', async () => {
|
||||
|
||||
Reference in New Issue
Block a user