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:
Brennan Benson
2026-10-15 00:52:16 -07:00
committed by GitHub
parent caa4155e61
commit e5bdc23f99
13 changed files with 108 additions and 59 deletions
@@ -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
}
}
}
@@ -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([])
@@ -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]