mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
* fix(codex): start a new thread when a chat's thread was never saved A structured Codex chat records its thread at create time, but Codex writes no rollout until the first input. After a restart, launch resumed that thread, Codex answered "no rollout found for thread id", and the chat could never run again. When the head of the handle chain is the session's own creation and Codex answers that exact error for that exact thread, start a new thread instead. The new link supersedes the unsaved creation in place and names it, so the chain keeps one live identity and does not grow across restarts. A thread a resume, fork or adoption proved is never superseded, and no other resume error starts fresh. * test(codex): build launch-resolution chains without a type assertion * fix(codex): match only Codex's own no-rollout text, pinned through the real connection The fallback matched Orca's own error-wrapper prefix too, and every test built that string itself, so rewording the wrapper would have disabled the fallback with the suite green. Match the method, code -32600 and Codex's exact detail as the message suffix, and drive Codex's raw error frame through the real connection in a test. The link builder now refuses, at the type level, a supersession on an adopted or resumed link, which the chain would reject downstream anyway. * docs(codex): note why the no-rollout text is safe on the resume path
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
agentSessionProviderHandleChainHead,
|
|
agentSessionProviderHandleKey,
|
|
agentSessionProviderHandleRoot,
|
|
agentSessionProviderHandlesEqual,
|
|
appendAgentSessionProviderHandleLink,
|
|
findAgentSessionProviderHandleLink,
|
|
isAgentSessionProviderHandle,
|
|
isAgentSessionHandleProvider,
|
|
isAgentSessionProviderHandleChain,
|
|
MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS,
|
|
type AgentSessionProviderHandle,
|
|
type AgentSessionProviderHandleLink
|
|
} from './agent-session-provider-handle'
|
|
|
|
const CLAUDE: AgentSessionProviderHandle = {
|
|
provider: 'claude',
|
|
sessionId: 'sess-1',
|
|
leafUuid: 'leaf-1'
|
|
}
|
|
|
|
function link(overrides: Partial<AgentSessionProviderHandleLink> = {}) {
|
|
return {
|
|
linkId: 'link-1',
|
|
handle: CLAUDE,
|
|
origin: 'created',
|
|
mintedAtFence: 1,
|
|
observedAt: 1_000,
|
|
...overrides
|
|
} as AgentSessionProviderHandleLink
|
|
}
|
|
|
|
describe('handle identity', () => {
|
|
it('rejects unknown persisted provider names instead of defaulting to Codex', () => {
|
|
expect(isAgentSessionHandleProvider('codex')).toBe(true)
|
|
expect(isAgentSessionHandleProvider('claude')).toBe(true)
|
|
expect(isAgentSessionHandleProvider('gemini')).toBe(false)
|
|
expect(isAgentSessionHandleProvider(undefined)).toBe(false)
|
|
})
|
|
|
|
it('keys a Claude handle by session id AND leaf, so two branches are two handles', () => {
|
|
// Concurrent resumes branch one transcript silently; the session id alone cannot name a writer.
|
|
const branchA = agentSessionProviderHandleKey(CLAUDE)
|
|
const branchB = agentSessionProviderHandleKey({ ...CLAUDE, leafUuid: 'leaf-2' })
|
|
expect(branchA).not.toEqual(branchB)
|
|
expect(agentSessionProviderHandleRoot(CLAUDE)).toEqual(
|
|
agentSessionProviderHandleRoot({ ...CLAUDE, leafUuid: 'leaf-2' })
|
|
)
|
|
})
|
|
|
|
it('keys a Codex handle by thread id alone', () => {
|
|
const codex: AgentSessionProviderHandle = { provider: 'codex', threadId: 'thread-1' }
|
|
expect(agentSessionProviderHandleKey(codex)).toBe('codex:"thread-1"')
|
|
expect(agentSessionProviderHandleRoot(codex)).toBe('codex:"thread-1"')
|
|
expect(
|
|
agentSessionProviderHandlesEqual(codex, { provider: 'codex', threadId: 'thread-2' })
|
|
).toBe(false)
|
|
})
|
|
|
|
it('distinguishes a null leaf from an empty-string leaf and rejects malformed handles', () => {
|
|
expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: null })).toBe(true)
|
|
expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: '' })).toBe(false)
|
|
expect(isAgentSessionProviderHandle({ provider: 'claude', sessionId: '' })).toBe(false)
|
|
expect(isAgentSessionProviderHandle({ provider: 'gemini', sessionId: 'x' })).toBe(false)
|
|
expect(isAgentSessionProviderHandle({ ...CLAUDE, sessionId: ' sess-1 ' })).toBe(false)
|
|
})
|
|
|
|
it('uses collision-free keys when Claude ids contain delimiters', () => {
|
|
const left: AgentSessionProviderHandle = {
|
|
provider: 'claude',
|
|
sessionId: 'a#b',
|
|
leafUuid: 'c'
|
|
}
|
|
const right: AgentSessionProviderHandle = {
|
|
provider: 'claude',
|
|
sessionId: 'a',
|
|
leafUuid: 'b#c'
|
|
}
|
|
expect(agentSessionProviderHandleKey(left)).not.toBe(agentSessionProviderHandleKey(right))
|
|
expect(agentSessionProviderHandlesEqual(left, right)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('chain append', () => {
|
|
it('starts only from a created or adopted link', () => {
|
|
expect(appendAgentSessionProviderHandleLink([], link())).toEqual([link()])
|
|
expect(appendAgentSessionProviderHandleLink([], link({ origin: 'adopted' }))).toHaveLength(1)
|
|
expect(() => appendAgentSessionProviderHandleLink([], link({ origin: 'resumed' }))).toThrow(
|
|
'agent_session_provider_handle_invalid'
|
|
)
|
|
})
|
|
|
|
it('refuses to record a fork as a resume', () => {
|
|
// --fork-session keeps the original item ids; calling it a resume would claim continuity the
|
|
// provider never gave.
|
|
const chain = [link()]
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
chain,
|
|
link({
|
|
linkId: 'link-2',
|
|
origin: 'resumed',
|
|
handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' },
|
|
mintedAtFence: 2
|
|
})
|
|
)
|
|
).toThrow('agent_session_provider_handle_forked')
|
|
})
|
|
|
|
it('records a fork only with a new root and the seed it came from', () => {
|
|
const chain = [link()]
|
|
const forked = link({
|
|
linkId: 'link-2',
|
|
origin: 'forked',
|
|
handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' },
|
|
mintedAtFence: 2,
|
|
forkedFromKey: agentSessionProviderHandleKey(CLAUDE)
|
|
})
|
|
expect(appendAgentSessionProviderHandleLink(chain, forked)).toHaveLength(2)
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(chain, { ...forked, forkedFromKey: 'claude:other' })
|
|
).toThrow('agent_session_provider_handle_invalid')
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(chain, {
|
|
...forked,
|
|
handle: CLAUDE,
|
|
forkedFromKey: agentSessionProviderHandleKey(CLAUDE)
|
|
})
|
|
).toThrow('agent_session_provider_handle_invalid')
|
|
})
|
|
|
|
it('rejects a link minted under an older fence', () => {
|
|
const chain = [link({ mintedAtFence: 5 })]
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
chain,
|
|
link({
|
|
linkId: 'link-2',
|
|
origin: 'resumed',
|
|
handle: { ...CLAUDE, leafUuid: 'leaf-2' },
|
|
mintedAtFence: 4
|
|
})
|
|
)
|
|
).toThrow('agent_session_provider_handle_stale_fence')
|
|
})
|
|
|
|
it('rejects a provider change mid-chain', () => {
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
[link()],
|
|
link({
|
|
linkId: 'link-2',
|
|
origin: 'resumed',
|
|
handle: { provider: 'codex', threadId: 'thread-1' },
|
|
mintedAtFence: 2
|
|
})
|
|
)
|
|
).toThrow('agent_session_provider_handle_provider_mismatch')
|
|
})
|
|
|
|
it('treats re-proving the same handle at the same fence as a retry, not a new link', () => {
|
|
const chain = [link({ mintedAtFence: 3 })]
|
|
const retried = appendAgentSessionProviderHandleLink(
|
|
chain,
|
|
link({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 3 })
|
|
)
|
|
expect(retried).toHaveLength(1)
|
|
expect(retried[0]?.linkId).toBe('link-1')
|
|
// A later fence on the same handle is a genuine re-acquisition and does append.
|
|
expect(
|
|
appendAgentSessionProviderHandleLink(
|
|
chain,
|
|
link({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 4 })
|
|
)
|
|
).toHaveLength(2)
|
|
})
|
|
|
|
it('rejects reuse of a stable link id for a different proof', () => {
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
[link()],
|
|
link({
|
|
origin: 'resumed',
|
|
handle: { ...CLAUDE, leafUuid: 'leaf-2' },
|
|
mintedAtFence: 2
|
|
})
|
|
)
|
|
).toThrow('agent_session_provider_handle_invalid')
|
|
})
|
|
|
|
it('refuses to grow past the cap rather than dropping fork provenance', () => {
|
|
const chain: AgentSessionProviderHandleLink[] = [link()]
|
|
for (let index = 1; index < MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS; index += 1) {
|
|
chain.push(
|
|
link({
|
|
linkId: `link-${index + 1}`,
|
|
origin: 'resumed',
|
|
handle: { ...CLAUDE, leafUuid: `leaf-${index + 1}` },
|
|
mintedAtFence: index + 1
|
|
})
|
|
)
|
|
}
|
|
expect(chain).toHaveLength(MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS)
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
chain,
|
|
link({
|
|
linkId: 'link-overflow',
|
|
origin: 'resumed',
|
|
handle: { ...CLAUDE, leafUuid: 'leaf-overflow' },
|
|
mintedAtFence: 999
|
|
})
|
|
)
|
|
).toThrow('agent_session_provider_handle_chain_overflow')
|
|
expect(isAgentSessionProviderHandleChain(chain)).toBe(true)
|
|
expect(agentSessionProviderHandleChainHead(chain)?.linkId).toBe(
|
|
`link-${MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS}`
|
|
)
|
|
})
|
|
|
|
it('never mutates the chain it was given', () => {
|
|
const chain = [link()]
|
|
appendAgentSessionProviderHandleLink(
|
|
chain,
|
|
link({
|
|
linkId: 'link-2',
|
|
origin: 'resumed',
|
|
handle: { ...CLAUDE, leafUuid: 'leaf-2' },
|
|
mintedAtFence: 2
|
|
})
|
|
)
|
|
expect(chain).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('chain lookup and validation', () => {
|
|
it('finds a link by id and reports the head', () => {
|
|
const chain = appendAgentSessionProviderHandleLink(
|
|
[link()],
|
|
link({
|
|
linkId: 'link-2',
|
|
origin: 'resumed',
|
|
handle: { ...CLAUDE, leafUuid: 'leaf-2' },
|
|
mintedAtFence: 2
|
|
})
|
|
)
|
|
expect(findAgentSessionProviderHandleLink(chain, 'link-1')?.origin).toBe('created')
|
|
expect(findAgentSessionProviderHandleLink(chain, 'missing')).toBeNull()
|
|
expect(agentSessionProviderHandleChainHead(chain)?.linkId).toBe('link-2')
|
|
expect(agentSessionProviderHandleChainHead([])).toBeNull()
|
|
})
|
|
|
|
it('rejects a persisted chain that is over the cap or holds a malformed link', () => {
|
|
expect(isAgentSessionProviderHandleChain([{ ...link(), mintedAtFence: -1 }])).toBe(false)
|
|
expect(isAgentSessionProviderHandleChain([{ ...link(), linkId: 'not a link id!' }])).toBe(false)
|
|
expect(isAgentSessionProviderHandleChain([{ ...link(), forkedFromKey: 'claude:seed' }])).toBe(
|
|
false
|
|
)
|
|
expect(
|
|
isAgentSessionProviderHandleChain(
|
|
Array.from({ length: MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS + 1 }, (_value, index) =>
|
|
link({ linkId: `link-${index}` })
|
|
)
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('rejects persisted chains that bypass append invariants', () => {
|
|
expect(
|
|
isAgentSessionProviderHandleChain([
|
|
link(),
|
|
link({
|
|
linkId: 'link-2',
|
|
origin: 'created',
|
|
mintedAtFence: 2
|
|
})
|
|
])
|
|
).toBe(false)
|
|
expect(
|
|
isAgentSessionProviderHandleChain([
|
|
link(),
|
|
link({
|
|
origin: 'resumed',
|
|
handle: { ...CLAUDE, leafUuid: 'leaf-2' },
|
|
mintedAtFence: 2
|
|
})
|
|
])
|
|
).toBe(false)
|
|
expect(
|
|
isAgentSessionProviderHandleChain([
|
|
link(),
|
|
link({
|
|
linkId: 'link-2',
|
|
origin: 'resumed',
|
|
handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-2' },
|
|
mintedAtFence: 2
|
|
})
|
|
])
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('adopted chain heads', () => {
|
|
// What a resume-from-history builds: the create seeds an `adopted` head, then the provider's own
|
|
// proof lands on top of it.
|
|
const adopted = (overrides: Partial<AgentSessionProviderHandleLink> = {}) =>
|
|
link({
|
|
linkId: 'claude-1-sess-1-empty',
|
|
origin: 'adopted',
|
|
handle: { ...CLAUDE, leafUuid: null },
|
|
...overrides
|
|
})
|
|
|
|
it('appends a Claude resume that lands on the adopted root with a leaf', () => {
|
|
// The adopted head names no leaf; the provider answers with one. Same root, so it is a resume.
|
|
const resumed = link({
|
|
linkId: 'claude-1-sess-1-leaf-1',
|
|
origin: 'resumed',
|
|
handle: CLAUDE,
|
|
mintedAtFence: 1
|
|
})
|
|
const chain = appendAgentSessionProviderHandleLink([adopted()], resumed)
|
|
|
|
expect(chain.map((entry) => entry.origin)).toEqual(['adopted', 'resumed'])
|
|
expect(agentSessionProviderHandleChainHead(chain)).toBe(resumed)
|
|
})
|
|
|
|
it('elides a Claude re-proof of the identical adopted handle at the same fence', () => {
|
|
const chain = [adopted()]
|
|
const elided = appendAgentSessionProviderHandleLink(
|
|
chain,
|
|
link({
|
|
linkId: 'claude-1-sess-1-empty-retry',
|
|
origin: 'resumed',
|
|
handle: { ...CLAUDE, leafUuid: null },
|
|
mintedAtFence: 1
|
|
})
|
|
)
|
|
|
|
expect(elided).toEqual(chain)
|
|
})
|
|
|
|
it('appends a Codex resume only once the fence has moved', () => {
|
|
const codexAdopted = link({
|
|
linkId: 'codex-1-thread-1',
|
|
origin: 'adopted',
|
|
handle: { provider: 'codex', threadId: 'thread-1' }
|
|
})
|
|
const reproved = link({
|
|
linkId: 'codex-1-thread-1-retry',
|
|
origin: 'resumed',
|
|
handle: { provider: 'codex', threadId: 'thread-1' },
|
|
mintedAtFence: 1
|
|
})
|
|
|
|
// Codex's thread id is the whole key, so a same-fence re-proof can only ever be a retry.
|
|
expect(appendAgentSessionProviderHandleLink([codexAdopted], reproved)).toEqual([codexAdopted])
|
|
expect(
|
|
appendAgentSessionProviderHandleLink([codexAdopted], { ...reproved, mintedAtFence: 2 })
|
|
).toHaveLength(2)
|
|
})
|
|
|
|
it('refuses a second origin link on top of an adopted head', () => {
|
|
// Nothing re-origins a chain: a create landing here would erase where the conversation came from.
|
|
for (const origin of ['created', 'adopted'] as const) {
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
[adopted()],
|
|
link({ linkId: 'claude-2-sess-1-leaf-1', origin, handle: CLAUDE, mintedAtFence: 2 })
|
|
)
|
|
).toThrow('agent_session_provider_handle_invalid')
|
|
}
|
|
})
|
|
|
|
it('refuses a resume that landed on another conversation entirely', () => {
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
[adopted()],
|
|
link({
|
|
linkId: 'claude-1-sess-9-leaf-9',
|
|
origin: 'resumed',
|
|
handle: { provider: 'claude', sessionId: 'sess-9', leafUuid: 'leaf-9' },
|
|
mintedAtFence: 1
|
|
})
|
|
)
|
|
).toThrow('agent_session_provider_handle_forked')
|
|
})
|
|
|
|
it('accepts an adopted head as a persisted chain', () => {
|
|
expect(isAgentSessionProviderHandleChain([adopted()])).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('superseding a creation the provider never saved', () => {
|
|
const unsaved: AgentSessionProviderHandle = { provider: 'codex', threadId: 'thread-unsaved' }
|
|
const created = link({ linkId: 'codex-1-thread-unsaved', handle: unsaved })
|
|
function replacement(overrides: Partial<AgentSessionProviderHandleLink> = {}) {
|
|
return link({
|
|
linkId: 'codex-3-thread-new',
|
|
handle: { provider: 'codex', threadId: 'thread-new' },
|
|
mintedAtFence: 3,
|
|
supersedesKey: agentSessionProviderHandleKey(unsaved),
|
|
...overrides
|
|
})
|
|
}
|
|
|
|
it('replaces the unsaved creation instead of standing beside it', () => {
|
|
const chain = appendAgentSessionProviderHandleLink([created], replacement())
|
|
expect(chain).toEqual([replacement()])
|
|
expect(isAgentSessionProviderHandleChain(chain)).toBe(true)
|
|
// An unused chat reopened across many restarts stays one link long.
|
|
const again = appendAgentSessionProviderHandleLink(
|
|
chain,
|
|
replacement({
|
|
linkId: 'codex-5-thread-newer',
|
|
handle: { provider: 'codex', threadId: 'thread-newer' },
|
|
mintedAtFence: 5,
|
|
supersedesKey: agentSessionProviderHandleKey({ provider: 'codex', threadId: 'thread-new' })
|
|
})
|
|
)
|
|
expect(again).toHaveLength(1)
|
|
expect(again[0]?.handle).toEqual({ provider: 'codex', threadId: 'thread-newer' })
|
|
})
|
|
|
|
it('never supersedes a conversation a resume, fork or adoption proved', () => {
|
|
const resumed = link({ linkId: 'codex-2-thread-unsaved', origin: 'resumed', handle: unsaved })
|
|
expect(() => appendAgentSessionProviderHandleLink([created, resumed], replacement())).toThrow(
|
|
'agent_session_provider_handle_invalid'
|
|
)
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink([{ ...created, origin: 'adopted' }], replacement())
|
|
).toThrow('agent_session_provider_handle_invalid')
|
|
})
|
|
|
|
it('names exactly the head it replaces, on a new root, under a current fence', () => {
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
[created],
|
|
replacement({ supersedesKey: 'codex:"thread-other"' })
|
|
)
|
|
).toThrow('agent_session_provider_handle_invalid')
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink([created], replacement({ handle: unsaved }))
|
|
).toThrow('agent_session_provider_handle_invalid')
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink([created], replacement({ linkId: created.linkId }))
|
|
).toThrow('agent_session_provider_handle_invalid')
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
[{ ...created, mintedAtFence: 4 }],
|
|
replacement({ mintedAtFence: 3 })
|
|
)
|
|
).toThrow('agent_session_provider_handle_stale_fence')
|
|
})
|
|
|
|
it('carries supersession only on a creation, and never as a persisted second link', () => {
|
|
expect(
|
|
appendAgentSessionProviderHandleLink([], replacement({ origin: 'created' }))
|
|
).toHaveLength(1)
|
|
expect(() =>
|
|
appendAgentSessionProviderHandleLink(
|
|
[created],
|
|
replacement({ origin: 'resumed', handle: unsaved })
|
|
)
|
|
).toThrow('agent_session_provider_handle_invalid')
|
|
expect(isAgentSessionProviderHandleChain([created, replacement()])).toBe(false)
|
|
})
|
|
})
|