Files
orca/src/shared/structured-agent-session-create.test.ts
T
Brennan Benson 002320c17d fix(native-chat): verify the actual CLI conversation during resume (#29124)
* fix(native-chat): identify the owner when resume create is refused

* test(native-chat): consolidate transcript refusal cases

* test(native-chat): keep ownership coverage at the launch boundary

* test(native-chat): update adoption fixture for early ownership admission

* test(native-chat): align resume admission assertions with host ownership

* test(native-chat): keep RPC coverage focused on ownership proof

* test(native-chat): assert blank request presence before optional source

* fix(native-chat): admit the actual CLI source during chat creation

* fix(native-chat): satisfy source admission lint checks

* test(native-chat): provide the waiting hook prompt

* test(native-chat): isolate source admission fixtures from host homes

* test(native-chat): canonicalize adoption fixture directories

* test(native-chat): preserve early ownership admission in source stack

* fix(native-chat): preserve resume refusal behavior and honest transcript feedback

* fix(native-chat): retain import read evidence in typed result

* fix(native-chat): preserve admitted source and prepared execution home

* fix(chat): retain source evidence for dispatched adoption

* fix(chat): keep source capture within module limits

* test(chat): allow host evidence in create routing

* fix(native-chat): admit an unsaved transcript under a linked account folder

A missing transcript stayed unresolved while its account root resolved through
symlinks, so containment failed and a not-yet-saved conversation showed a load
error instead of the unsaved notice.

* refactor(native-chat): move the newest-item import window into its own module

Keeps journal-legacy-import.ts under the line limit after the source
identity observer landed on top of the streaming import.

* fix(native-chat): restore the refusal error import lost in the main merge

* fix(native-chat): admit Pi transcripts against the root Session History walks

A PI_CODING_AGENT_DIR that already ends in sessions, or any custom folder,
made admission expect <dir>/sessions while the lookup searched <dir>, so a
found conversation was refused.

* refactor(native-chat): admit and look up CLI transcripts in one store per account

Each agent's import described where an account keeps its conversations twice,
once for the path check and once for the lookup, and the two could disagree
(the linked-folder and Pi custom-root refusals). The import now names one
conversation store per account; the create path resolves it once and both the
hook-path check and the fallback lookup judge that same store. OpenCode's
store is its exact database.
2026-10-16 21:14:03 -07:00

178 lines
7.6 KiB
TypeScript

import { ResumeSource } from './rpc-contract/structured-agent-session-params'
import { STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY } from './structured-agent-session-surface-capabilities'
import { describe, expect, it } from 'vitest'
import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, RUNTIME_CAPABILITIES } from './protocol-version'
import {
AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY,
AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY
} from './agent-session-create-capabilities'
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from './electron-remote-runtime-client-capabilities'
import {
structuredAgentSessionCreateParams,
type StructuredAgentSessionFirstMessage,
type StructuredAgentSessionResumeSource
} from './structured-agent-session-create'
import {
structuredAgentSessionCreateFingerprint,
structuredAgentSessionPayloadFingerprint
} from './structured-agent-session-mutation'
const SESSION_ID = 'codex_11111111_2222_3333_4444_555555555555'
const RESUME = { providerSessionId: 'thread-abc' }
/** Distinct per call so two envelopes never share an operation id by accident. */
let uuidCounter = 0
function nextUuid(): string {
uuidCounter += 1
return `00000000-0000-4000-8000-${String(uuidCounter).padStart(12, '0')}`
}
function createParams(
overrides: {
resumeFrom?: StructuredAgentSessionResumeSource
tabId?: string
firstMessage?: StructuredAgentSessionFirstMessage
options?: Record<string, string>
} = {}
) {
return structuredAgentSessionCreateParams({
sessionId: SESSION_ID,
worktree: 'id:repo-1::/repo/orca',
agent: 'codex',
...overrides,
randomUuid: nextUuid,
now: 1_800_000_000_000
})
}
describe('structured agent session create params', () => {
it('advertises create-message support only on clients using the new flow', () => {
expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain(
AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY
)
expect(NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES).not.toContain(
AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY
)
})
it('fingerprints the first message identity, content and opening option picks', () => {
const firstMessage: StructuredAgentSessionFirstMessage = {
clientMessageId: 'message-1',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'opening text' }] }
}
const first = createParams({ firstMessage, options: { model: 'picked-model' } })
expect(first.firstMessage).toEqual(firstMessage)
expect(first.options).toEqual({ model: 'picked-model' })
expect(first.envelope.payloadFingerprint).toBe(
createParams({ firstMessage, options: { model: 'picked-model' } }).envelope.payloadFingerprint
)
for (const changed of [
createParams(),
createParams({
firstMessage: { ...firstMessage, clientMessageId: 'message-2' },
options: { model: 'picked-model' }
}),
createParams({
firstMessage: {
...firstMessage,
body: { ...firstMessage.body, blocks: [{ type: 'text', text: 'different text' }] }
},
options: { model: 'picked-model' }
}),
createParams({ firstMessage, options: { model: 'other-model' } })
]) {
expect(changed.envelope.payloadFingerprint).not.toBe(first.envelope.payloadFingerprint)
}
expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY)
expect(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY).toBe('agentSession.create.message.v1')
})
it('carries resumeFrom only when the create adopts a conversation', () => {
expect(createParams()).not.toHaveProperty('resumeFrom')
expect(createParams({ resumeFrom: RESUME })).toMatchObject({ resumeFrom: RESUME })
})
it('declares a fingerprint the host can recompute from the same fields', () => {
const params = createParams({ resumeFrom: RESUME })
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionCreateFingerprint({
sessionId: SESSION_ID,
worktree: 'id:repo-1::/repo/orca',
agent: 'codex',
resumeFrom: RESUME
})
)
})
it('separates an adopting create from a blank one and from another row', () => {
const blank = createParams().envelope.payloadFingerprint
const adopted = createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint
const otherRow = createParams({
resumeFrom: { providerSessionId: 'thread-other' }
}).envelope.payloadFingerprint
expect(adopted).not.toBe(blank)
expect(otherRow).not.toBe(adopted)
})
it('gives a replay of the same adoption the same digest under a new operation id', () => {
const first = createParams({ resumeFrom: RESUME })
const second = createParams({ resumeFrom: RESUME })
expect(second.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId)
expect(second.envelope.payloadFingerprint).toBe(first.envelope.payloadFingerprint)
})
it('leaves a blank create byte-identical to the pre-resume digest', () => {
// Pinned literal: a create with no `resumeFrom` must keep the digest older clients and hosts
// already compute, so adding a field to the create fingerprint fails here rather than in the
// field on a mixed-version pair.
expect(createParams().envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION_ID,
fields: { worktree: 'id:repo-1::/repo/orca', agent: 'codex' }
})
)
expect(createParams().envelope.payloadFingerprint).toBe(
'56cb15e22414c0f62fd89d77d00d2d6a0a422f16e95edee154fb8b5bf53fbbc3'
)
})
})
describe('the tab id a create reserves', () => {
it('rides the params and the fingerprint together', () => {
const params = createParams({ tabId: 'tab-1' })
expect(params.tabId).toBe('tab-1')
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionCreateFingerprint({
sessionId: SESSION_ID,
worktree: 'id:repo-1::/repo/orca',
agent: 'codex',
tabId: 'tab-1'
})
)
// The declared digest covers the tab; the host still replays a retry on its attach fingerprint.
expect(params.envelope.payloadFingerprint).not.toBe(createParams().envelope.payloadFingerprint)
})
it('is advertised as a capability, because an older host refuses the strict payload', () => {
expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY)
expect(AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY).toBe('agentSession.create.tab-id.v1')
})
})
it('negotiates a strict pane identity and includes it in the create fingerprint without accepting paths', () => {
const source = { providerSessionId: 'thread-abc', cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } }
expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY)
expect(ResumeSource.parse(source)).toEqual(source)
expect(ResumeSource.safeParse({ ...source, transcriptPath: '/client/path' }).success).toBe(false)
expect(ResumeSource.safeParse({ ...source, cliSource: { ...source.cliSource, accountHome: '/client/home' } }).success).toBe(false)
const first = createParams({ resumeFrom: source })
expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint)
expect(first.envelope.payloadFingerprint).toBe(createParams({ resumeFrom: source }).envelope.payloadFingerprint)
expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: {
...source, cliSource: { ...source.cliSource, paneKey: 'other:leaf' }
} }).envelope.payloadFingerprint)
})