mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(native-chat): recognize missing history before chat creation (#28261)
* fix(native-chat): recognize missing transcripts refused before create * Classify vanished transcript during precommit import * test: expect uncertainty context on resumed chat launches * test: keep only the tests that pin user-visible behavior
This commit is contained in:
+7
-3
@@ -139,12 +139,16 @@ it('refuses concurrent founding under a tab another chat already owns', async ()
|
||||
expect(rig.adapter.acquire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates adoption history before founding anything', async () => {
|
||||
it('refuses history lost after discovery before founding anything', async () => {
|
||||
const params = await adoptedParams()
|
||||
await writeFile(join(rig.root, 'rollout.jsonl'), 'not a transcript\n')
|
||||
await rm(join(rig.root, 'rollout.jsonl'))
|
||||
expect(await rig.host.create(CALLER, params)).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_identity_required' }
|
||||
refusal: {
|
||||
code: 'agent_session_identity_required',
|
||||
details: { reason: 'transcriptUnreadable' },
|
||||
createAttemptRefusedBeforeCommit: true
|
||||
}
|
||||
})
|
||||
expect(rig.store.listRecords()).toEqual([])
|
||||
expect(rig.store.listOperationRows()).toEqual([])
|
||||
|
||||
@@ -153,7 +153,15 @@ async function foundConversation(
|
||||
? { ok: true as const, items: null }
|
||||
: await prepareAdoptedTranscript(params)
|
||||
if (!prepared.ok) {
|
||||
return prepared
|
||||
const { refusal } = prepared
|
||||
return {
|
||||
ok: false,
|
||||
refusal:
|
||||
refusal.code === 'agent_session_identity_required' &&
|
||||
refusal.details?.reason === 'transcriptUnreadable'
|
||||
? { ...refusal, createAttemptRefusedBeforeCommit: true }
|
||||
: refusal
|
||||
}
|
||||
}
|
||||
const existing = context.sessions.get(sessionId)
|
||||
const opened = existing
|
||||
|
||||
@@ -8,6 +8,7 @@ import { setStructuredAgentSessionHost } from '../../../native-chat/agent-sessio
|
||||
import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
|
||||
import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal'
|
||||
import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals'
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RpcResponse } from '../core'
|
||||
@@ -130,18 +131,20 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('a create refused before it commits', () => {
|
||||
it('answers a code-carrying refusal as a definitive envelope', async () => {
|
||||
it('answers missing history before commit as a definite failed attempt', async () => {
|
||||
const response = await create({
|
||||
resolveStructuredAgentSessionCreateIntent: vi.fn(async () => {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
throw agentSessionRefusalError('agent_session_identity_required', {
|
||||
reason: 'transcriptNotFound'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const refusal = refusalOf(response)
|
||||
expect(refusal?.code).toBe('structured_agent_session_unsupported')
|
||||
expect(refusal?.message).toContain('structured agent chat')
|
||||
expect(refusal?.message).not.toContain('Codex')
|
||||
expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(true)
|
||||
expect(refusalOf(response)).toMatchObject({
|
||||
code: 'agent_session_identity_required',
|
||||
details: { reason: 'transcriptNotFound' },
|
||||
createAttemptRefusedBeforeCommit: true
|
||||
})
|
||||
expect(attach).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -75,6 +75,13 @@ export async function resolveUncommittedStructuredCreate<TPrepared>(
|
||||
try {
|
||||
return await prepare()
|
||||
} catch (error) {
|
||||
return { refusal: precommitRefusal(error) }
|
||||
const refusal = precommitRefusal(error)
|
||||
return {
|
||||
refusal:
|
||||
refusal.code === 'agent_session_identity_required' &&
|
||||
refusal.details?.reason === 'transcriptNotFound'
|
||||
? { ...refusal, createAttemptRefusedBeforeCommit: true }
|
||||
: refusal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -328,7 +328,8 @@ describe('a chat whose start was never confirmed', () => {
|
||||
envelope: expect.objectContaining({ clientOperationId: `operation-${CLAUDE_SESSION}` })
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(lifecycle()).toBeNull()
|
||||
expect(rows()).toEqual([])
|
||||
|
||||
+9
-7
@@ -102,8 +102,14 @@ describe('resumeAiVaultSessionInNewChat', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('toasts a conflict reported by the eventual settlement', async () => {
|
||||
const error = Object.assign(new Error('held'), { code: 'agent_session_conflict' })
|
||||
it.each([
|
||||
['agent_session_conflict', 'Another chat is already holding this conversation.'],
|
||||
[
|
||||
'agent_session_identity_required',
|
||||
"This conversation's history could not be loaded, so it cannot be resumed in chat."
|
||||
]
|
||||
])('toasts the eventual %s refusal', async (code, message) => {
|
||||
const error = Object.assign(new Error('Host diagnostic'), { code })
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue({
|
||||
sessionId: 'session-1',
|
||||
tab: { id: 'agent-session:session-1' },
|
||||
@@ -111,11 +117,7 @@ describe('resumeAiVaultSessionInNewChat', () => {
|
||||
})
|
||||
|
||||
await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click')
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
'Another chat is already holding this conversation.'
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith(message))
|
||||
})
|
||||
|
||||
it('keeps unknown outcomes silent for reconciliation', async () => {
|
||||
|
||||
@@ -499,23 +499,6 @@ describe('structured agent session launch', () => {
|
||||
expect(error).toBeInstanceOf(StructuredAgentSessionCreateUnknownOutcomeError)
|
||||
})
|
||||
|
||||
it('preserves a definitive refusal code for the fallback path', async () => {
|
||||
mockSupportedCreate(() => ({
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'structured_agent_session_unsupported',
|
||||
message: 'Structured chat is unavailable.'
|
||||
}
|
||||
}))
|
||||
|
||||
const error = await launchStructuredAgentSession(
|
||||
createStructuredAgentSessionLaunchIntent('workspace-unsupported', 'codex')
|
||||
).catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toBeInstanceOf(StructuredAgentSessionCreateRefusalError)
|
||||
expect(error).toMatchObject({ code: 'structured_agent_session_unsupported' })
|
||||
})
|
||||
|
||||
it.each(['method_not_found', 'structured_agent_session_unsupported'])(
|
||||
'turns an old-host %s error into a definitive transport refusal',
|
||||
async (code) => {
|
||||
|
||||
@@ -78,7 +78,8 @@ export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void
|
||||
|
||||
export async function launchStructuredAgentSession(
|
||||
intent: StructuredAgentSessionLaunchIntent,
|
||||
onHostSeed?: StructuredLaunchHostSeedListener
|
||||
onHostSeed?: StructuredLaunchHostSeedListener,
|
||||
priorCreateOutcomeUnknown = false
|
||||
): Promise<StructuredAgentLaunchReceipt> {
|
||||
const hostSeed = await requireHostCreateSupport(intent)
|
||||
if (intent.target.kind !== 'local') {
|
||||
@@ -119,8 +120,18 @@ export async function launchStructuredAgentSession(
|
||||
if (!result.ok) {
|
||||
const { code, message, ownerVerdict } = result.refusal
|
||||
const refusal = readAgentSessionRefusalReference(result.refusal)
|
||||
// A failed operation whose provider is proven gone is a failure a new operation may retry.
|
||||
if (!isDefinitiveAgentSessionCreateRefusal(code) && ownerVerdict !== 'exited') {
|
||||
const transcriptRefusedBeforeCommit =
|
||||
!priorCreateOutcomeUnknown &&
|
||||
result.refusal.createAttemptRefusedBeforeCommit === true &&
|
||||
refusal?.code === 'agent_session_identity_required' &&
|
||||
(refusal.details?.reason === 'transcriptNotFound' ||
|
||||
refusal.details?.reason === 'transcriptUnreadable')
|
||||
// A proven precommit transcript refusal lets a new operation retry.
|
||||
if (
|
||||
!transcriptRefusedBeforeCommit &&
|
||||
!isDefinitiveAgentSessionCreateRefusal(code) &&
|
||||
ownerVerdict !== 'exited'
|
||||
) {
|
||||
// Keep the focus intent: the session may exist, and recovery still has to adopt it.
|
||||
throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code, refusal)
|
||||
}
|
||||
|
||||
@@ -12,10 +12,7 @@ export class StructuredAgentSessionCreateError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The host proved it created nothing. The class itself is the verdict:
|
||||
* `launchStructuredAgentSession` is the only place that decides it against the shared allowlist.
|
||||
*/
|
||||
/** A known create failure; the launch classifier keeps uncertain earlier attempts recoverable. */
|
||||
export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSessionCreateError {
|
||||
constructor(
|
||||
message: string,
|
||||
|
||||
@@ -46,6 +46,7 @@ vi.mock('@/lib/agent-catalog', () => ({
|
||||
import {
|
||||
getStructuredAgentSessionLaunchLifecycle,
|
||||
retryStructuredAgentSessionLaunch,
|
||||
shouldRetainStructuredAgentSessionLaunchTab,
|
||||
startStructuredAgentLaunch
|
||||
} from './structured-agent-session-launch'
|
||||
import {
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
resetStructuredAgentLaunchRegistryForTests
|
||||
} from './structured-agent-session-launch-registry'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
|
||||
import { structuredSessionNotices } from '@/components/native-chat/native-chat-structured-session-notices'
|
||||
|
||||
const WORKTREE = 'wt-1'
|
||||
const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail'
|
||||
@@ -62,6 +64,7 @@ const HOST_REFUSAL = { kind: 'refused', code: 'agent_session_operation_invalid'
|
||||
let replayVerdict: AgentSessionOwnerVerdict | undefined
|
||||
/** A host that answers the proven exit on the first call, not only on replay. */
|
||||
let refuseFirstCall = false
|
||||
let missingHistory: boolean | 'legacy' = false
|
||||
let failedOperation: string | null = null
|
||||
let publishedSessionId: string | null = null
|
||||
const createdOperations: string[] = []
|
||||
@@ -79,7 +82,13 @@ function hostCreate(envelope: AgentSessionMutationEnvelope): unknown {
|
||||
throw new Error(EXIT_REASON)
|
||||
}
|
||||
const refusal: AgentSessionWireRefusal = {
|
||||
code: 'agent_session_operation_invalid',
|
||||
...(missingHistory
|
||||
? {
|
||||
code: 'agent_session_identity_required' as const,
|
||||
details: { reason: 'transcriptNotFound' as const },
|
||||
...(missingHistory === true ? { createAttemptRefusedBeforeCommit: true as const } : {})
|
||||
}
|
||||
: { code: 'agent_session_operation_invalid' as const }),
|
||||
message: EXIT_REASON,
|
||||
...(replayVerdict ? { ownerVerdict: replayVerdict } : {})
|
||||
}
|
||||
@@ -106,6 +115,7 @@ beforeEach(() => {
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
replayVerdict = 'exited'
|
||||
refuseFirstCall = false
|
||||
missingHistory = false
|
||||
failedOperation = null
|
||||
publishedSessionId = null
|
||||
createdOperations.length = 0
|
||||
@@ -144,13 +154,26 @@ describe('structured launch after a host-failed create', () => {
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBeNull()
|
||||
})
|
||||
|
||||
it('fails on the first answer when the host already refuses with the proven exit', async () => {
|
||||
it('retains a missing-history failed tab with Retry, then starts a new attempt', async () => {
|
||||
refuseFirstCall = true
|
||||
missingHistory = true
|
||||
replayVerdict = undefined
|
||||
const sessionId = launch()
|
||||
await settle(sessionId)
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('failed')
|
||||
// The host's message is for its log; the Retry line words the refusal.
|
||||
expect(getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId)).toEqual(HOST_REFUSAL)
|
||||
expect(shouldRetainStructuredAgentSessionLaunchTab(WORKTREE, sessionId)).toBe(true)
|
||||
expect(
|
||||
structuredSessionNotices({
|
||||
launch: {
|
||||
lifecycle: 'failed',
|
||||
failure: getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId),
|
||||
retry: () => {}
|
||||
},
|
||||
agentLabel: 'Codex',
|
||||
sessionError: null,
|
||||
composerError: null
|
||||
})
|
||||
).toMatchObject([{ text: 'Chat could not be started.', action: { label: 'Retry' } }])
|
||||
expect(createdOperations).toEqual([failedOperation])
|
||||
expect(retryStructuredAgentSessionLaunch(WORKTREE, sessionId)).toBe(true)
|
||||
await settle(sessionId)
|
||||
@@ -160,13 +183,20 @@ describe('structured launch after a host-failed create', () => {
|
||||
|
||||
it.each([
|
||||
['the host could not prove the process gone', 'unverifiable'],
|
||||
['the host predates the verdict', undefined]
|
||||
] as const)('stays unknown when %s', async (_label, verdict) => {
|
||||
replayVerdict = verdict
|
||||
const sessionId = launch()
|
||||
await settle(sessionId)
|
||||
['the host predates the missing-history proof', undefined, 'legacy'],
|
||||
['an earlier reply was lost before the missing-history refusal', undefined, true]
|
||||
] as const)(
|
||||
'stays unknown when %s',
|
||||
async (_label, verdict, transcriptRefusal: boolean | 'legacy' = false) => {
|
||||
replayVerdict = verdict
|
||||
missingHistory = transcriptRefusal
|
||||
const sessionId = launch()
|
||||
await settle(sessionId)
|
||||
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('visibility-unknown')
|
||||
expect(createdOperations.every((operation) => operation === failedOperation)).toBe(true)
|
||||
})
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe(
|
||||
'visibility-unknown'
|
||||
)
|
||||
expect(createdOperations.every((operation) => operation === failedOperation)).toBe(true)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function recoverPublishedSessionReceipt(
|
||||
)
|
||||
: undefined
|
||||
if (state.intent.params.firstMessage && !firstMessage) {
|
||||
return launchStructuredAgentSession(state.intent, state.onHostSeed)
|
||||
return launchStructuredAgentSession(state.intent, state.onHostSeed, true)
|
||||
}
|
||||
if (state.intent.createMessageSupport === true) {
|
||||
publishStructuredAgentSessionCreateHydration(state.intent, history.page, fence)
|
||||
@@ -88,7 +88,7 @@ async function retrySameIntent(
|
||||
): Promise<StructuredAgentLaunchReceipt> {
|
||||
throwIfLaunchCancelled(state)
|
||||
try {
|
||||
const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed)
|
||||
const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed, true)
|
||||
throwIfLaunchCancelled(state)
|
||||
if (state.intent.createMessageSupport !== true) {
|
||||
await verifyPublishedSession(state)
|
||||
|
||||
@@ -178,7 +178,8 @@ describe('structured agent launch reload recovery', () => {
|
||||
})
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,8 @@ export type AgentSessionRefusalOf<C extends AgentSessionWireRefusalCode> = {
|
||||
message: string
|
||||
/** Absent from older hosts. */
|
||||
details?: AgentSessionRefusalDetails<K>
|
||||
/** This create attempt refused before commit; earlier attempts may still have committed. */
|
||||
createAttemptRefusedBeforeCommit?: true
|
||||
} & AgentSessionLegacyRefusalFields
|
||||
}[C]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user