fix(native-chat): keep damaged chats readable without granting agent actions (#31931)

* fix(native-chat): validate saved next-fence floors before use

* WIP: separate saved conversation observations from runtime proof

* Consolidate duplicate imports for WIP compiler checkpoint

* Preserve saved conversation observations with strict runtime authority

* Consolidate session admission and catalog imports

* fix: align conversation observation contracts and fixtures

* test: update conversation observation and writer fixtures

* test: use checked host fixtures for structured workers

* fix(native-chat): bound conversation projection work per request

* test(native-chat): consolidate scale fixture imports

* test(native-chat): use checked scale fixture types

* test(native-chat): bound unreadable inventory traversal

* Test native CLI admission and durable reference reads

* test: bound cold conversation name subscription work

* test: distinguish native receipt probes from terminal identity

* test: retain exact mutation refusal recovery identity

* fix: explain agent conversation preparation refusals

* test: adapt remaining conversation readers and document CLI timeout

* fix: register preparation refusal English message

* test: align conversation fixtures with checked read APIs

* test(native-chat): use checked conversation in Codex fixture
This commit is contained in:
Brennan Benson
2026-10-26 13:16:41 -07:00
committed by GitHub
parent d6567867e5
commit 9ae33a96f9
244 changed files with 4828 additions and 1172 deletions
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
// Regression: initialize listed Grok 4.7 while new sessions actually ran Grok 4.6.
import { afterEach, describe, expect, it } from 'vitest'
@@ -44,7 +45,9 @@ const OTHER_CHAT = 'grok-chat-2'
function otherChat(store: AgentSessionRecordStore | null) {
const first = store?.getRecord(SESSION)
return first ? { ...first, sessionId: OTHER_CHAT } : undefined
return first
? { ...first, sessionId: OTHER_CHAT, lease: { ...first.lease, sessionId: OTHER_CHAT } }
: undefined
}
const agents: AcpScriptedAgent[] = []
@@ -112,6 +115,11 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe
const discovery = GROK.modelDiscovery
const catalog = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
getConversation: (id) => {
const saved =
records.store?.getRecord(id) ?? (id === OTHER_CHAT ? otherChat(records.store) : undefined)
return saved ? agentSessionConversationView(saved) : undefined
},
getRecord: (sessionId) =>
records.store?.getRecord(sessionId) ??
(sessionId === OTHER_CHAT ? otherChat(records.store) : undefined),
@@ -24,6 +24,7 @@ describe('a live Grok listing', () => {
const catalog = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
getRecord: (id) => records?.getRecord(id) ?? undefined,
getConversation: (id) => records?.getConversation(id) ?? undefined,
drivesRecord: () => true,
resolveAccountHome: async () => ({ variable: 'GROK_HOME', path: '/grok' }),
recordWorkspacePath: async () => '/workspace/project',
@@ -152,7 +152,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
}
]
]),
getRecord: () => null,
getConversation: () => null,
now: () => 1,
onStatusChanged: observe
})
@@ -252,7 +252,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
const feed = new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]),
getRecord: () => null,
getConversation: () => null,
now: () => 1,
onStatusChanged: (summary, options) => {
expect(summary.workspaceId).toBe(workspaceId)
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -39,7 +40,13 @@ function install(record: AgentSessionRecord, copy = vi.fn(), journal = savedJour
setStructuredAgentSessionHost({
journalSnapshot: async () => journal,
deps: {
store: { getRecord: () => record, listRecords: () => [record] },
store: {
getRecord: () => record,
listRecords: () => [record],
listConversations: () => [agentSessionConversationView(record)],
selectWritableRecord: () => ({ kind: 'writable', record }),
requireWritableRecord: () => record
},
adapter: { copyConversation: copy },
resolveWorkspacePath: async () => root,
resolveLaunchEnv: () => ({})
+18 -1
View File
@@ -77,7 +77,10 @@ export async function prepareStructuredChatCliFork(
prepare: AiVaultSessionResumePreparation
): Promise<AiVaultPrepareSessionResumeResult> {
const host = getStructuredAgentSessionHost()
const record = host?.deps.store.getRecord(args.sourceChat.sessionId)
const record =
host && host.deps.store.selectWritableRecord(args.sourceChat.sessionId).kind !== 'missing'
? host.deps.store.requireWritableRecord(args.sourceChat.sessionId)
: undefined
const registration = record && structuredAgentRuntimeRegistration(record.provider)
const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain)
if (
@@ -102,6 +105,18 @@ export async function prepareStructuredChatCliFork(
) {
throw new Error('Send a message after rewinding this chat, then open it in a terminal.')
}
const requireCurrent = (): void => {
const current = host.deps.store.requireWritableRecord(record.sessionId)
const currentHead = agentSessionProviderHandleChainHead(current.providerHandleChain)
if (
current.location.workspaceId !== record.location.workspaceId ||
current.location.executionHostId !== record.location.executionHostId ||
current.location.wslDistro !== record.location.wslDistro ||
currentHead?.linkId !== head.linkId
) {
throw new Error('This chat has no saved conversation available on this host.')
}
}
const agent = record.provider
const cwd =
(await host.deps.resolveWorkspacePath?.(record.location.workspaceId)) || record.launchDirectory
@@ -114,6 +129,7 @@ export async function prepareStructuredChatCliFork(
}
assertNativeConversationContent(await host.journalSnapshot(record.sessionId))
if (agentForksThroughChat(agent)) {
requireCurrent()
const result = await copyStructuredChatRecordConversation(record, providerSessionId)
const copy = result.forkedSession
if (!copy || (agent === 'omp' && !copy.filePath)) {
@@ -164,6 +180,7 @@ export async function prepareStructuredChatCliFork(
agent === 'codex' && isLegacyAgentSessionAccountHome(record.accountHome)
? record.accountHome.path
: null
requireCurrent()
const result = await prepare({
agent,
sessionId: providerSessionId,
@@ -18,7 +18,12 @@ export async function copyStructuredChatConversation(
}
const host = getStructuredAgentSessionHost()
const ownership = findResumeOwnership(args)
const record = ownership ? host?.deps.store.getRecord(ownership.sessionId) : undefined
const record =
ownership &&
host &&
host.deps.store.selectWritableRecord(ownership.sessionId).kind !== 'missing'
? host.deps.store.requireWritableRecord(ownership.sessionId)
: undefined
const copy = host?.deps.adapter.copyConversation
if (!host || !ownership || !record || !copy) {
throw new Error('This chat is no longer in Orca, so it cannot be copied to a new CLI.')
@@ -32,7 +37,11 @@ export async function copyStructuredChatRecordConversation(
): Promise<AiVaultPrepareSessionResumeResult> {
const host = getStructuredAgentSessionHost()
const copy = host?.deps.adapter.copyConversation
if (!host || !copy || !isAiVaultAgent(record.provider)) {
if (!host) {
throw new Error('This chat cannot be copied to a new CLI.')
}
record = host.deps.store.requireWritableRecord(record.sessionId)
if (!copy || !isAiVaultAgent(record.provider)) {
throw new Error('This chat cannot be copied to a new CLI.')
}
const forkedSession = await copy.call(host.deps.adapter, {
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import {
agentSessionLeaseFixture,
@@ -26,32 +27,39 @@ export function installOwnership(
workspaceId: 'workspace-1',
provider: handle?.agent ?? 'codex',
providerSessionId: PROVIDER_SESSION,
lease: agentSessionLeaseFixture(),
ownerObservation: agentSessionConversationView(agentSessionRecordFixture()).ownerObservation,
...ownershipOverrides
}
const record = agentSessionRecordFixture(ownership.lease)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached.
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({ sessionId: ownership.sessionId })
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listConversations`; the rest of the host is never reached.
setStructuredAgentSessionHost({
deps: {
store: {
listRecords: () => [
listConversations: () => [
{
...record,
sessionId: ownership.sessionId,
location: { ...record.location, workspaceId: ownership.workspaceId },
provider: ownership.provider,
providerHandleChain: [
{
...record.providerHandleChain[0]!,
handle:
handle ??
(ownership.provider === 'claude'
? claudeProviderHandle(ownership.providerSessionId, null)
: codexProviderHandle(ownership.providerSessionId))
}
],
lease: { ...ownership.lease, sessionId: ownership.sessionId },
...(ownership.conversationName ? { conversationName: ownership.conversationName } : {})
...agentSessionConversationView({
...record,
sessionId: ownership.sessionId,
location: { ...record.location, workspaceId: ownership.workspaceId },
provider: ownership.provider,
providerHandleChain: [
{
...record.providerHandleChain[0]!,
handle:
handle ??
(ownership.provider === 'claude'
? claudeProviderHandle(ownership.providerSessionId, null)
: codexProviderHandle(ownership.providerSessionId))
}
],
lease: record.lease,
...(ownership.conversationName
? { conversationName: ownership.conversationName }
: {})
}),
ownerObservation: ownership.ownerObservation
}
]
}
@@ -79,6 +87,9 @@ export function installCopyingChat(
deps: {
store: {
listRecords: () => [record, ...otherRecords],
listConversations: () => [record, ...otherRecords].map(agentSessionConversationView),
selectWritableRecord: () => ({ kind: 'writable', record }),
requireWritableRecord: () => record,
getRecord: (sessionId: string) => (sessionId === record.sessionId ? record : null)
},
adapter: { copyConversation: copy }
@@ -104,8 +115,10 @@ export function codexChatHolding(sessionId: string, threadIds: string[]): AgentS
}
export function installChats(records: AgentSessionRecord[]): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached.
setStructuredAgentSessionHost({ deps: { store: { listRecords: () => records } } } as never)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listConversations`; the rest of the host is never reached.
setStructuredAgentSessionHost({
deps: { store: { listConversations: () => records.map(agentSessionConversationView) } }
} as never)
}
export function listResult(): AiVaultListResult {
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it } from 'vitest'
import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture'
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import {
assertLegacyAiVaultResumeAllowed,
@@ -155,11 +159,15 @@ describe('structured AI Vault ownership', () => {
).toThrow('agent_session_conflict')
installOwnership({
lease: agentSessionLeaseFixture({
handoffStage: 'new-owner-proving',
claimStatus: 'reserved',
ownerProcess: null
})
ownerObservation: agentSessionConversationView(
agentSessionRecordFixture(
agentSessionLeaseFixture({
handoffStage: 'new-owner-proving',
claimStatus: 'reserved',
ownerProcess: null
})
)
).ownerObservation
})
await expect(
assertLegacyAiVaultResumeCommandAllowed(
@@ -1,5 +1,5 @@
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication'
import { agentSessionOwnerObservationIsLive } from '../../shared/agent-session-lease-adjudication'
import { defaultAgentChatLabel } from '../../shared/agent-session/agent-session-chat-label'
import type { AiVaultListResult } from '../../shared/ai-vault-types'
import { historyRowFallsBackToChatLabel } from '../../shared/structured-chat-row-name'
@@ -202,7 +202,7 @@ function resumeOwnerships(
function listOwnership(): StructuredProviderSessionOwnership[] {
const host = getStructuredAgentSessionHost()
return host ? listStructuredProviderSessionOwnership(host.deps.store.listRecords()) : []
return host ? listStructuredProviderSessionOwnership(host.deps.store.listConversations()) : []
}
function resumesOwnedSession(
@@ -258,7 +258,7 @@ function parseResumeInvocations(command: string): ResumeInvocation[] {
function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never {
throw new Error(
agentSessionLeaseAdmitsWriter(ownership.lease)
agentSessionOwnerObservationIsLive(ownership.ownerObservation)
? 'agent_session_conflict'
: 'agent_session_ownership_unknown'
)
@@ -63,7 +63,7 @@ async function wiredSession() {
}
]
]),
getRecord: () => null,
getConversation: () => null,
now: () => Date.now(),
statusSink: () => ({
publish: (summary, subject) => host.ingestStructuredStatus(summary, subject),
@@ -1,4 +1,8 @@
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -92,6 +96,10 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist
child: { fence: 1, generation: 'generation-1' },
params: { provider: 'claude' }
}
const record = {
...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: SESSION, runtimeFence: 1 })),
options: { model: 'opus' }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the options read touches only these members.
const context = {
deps: {
@@ -100,12 +108,8 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist
},
agents,
store: {
getRecord: () => ({
...agentSessionRecordFixture(),
sessionId: SESSION,
provider: 'claude',
options: { model: 'opus' }
})
requireWritableRecord: () => record,
getConversation: () => agentSessionConversationView(record)
}
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
@@ -1,3 +1,6 @@
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import { describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { getAgentSessionOptionCatalog } from '../../shared/agent-session-option-catalog'
@@ -142,19 +145,22 @@ async function startChild(
function restingRecord(
options: Record<string, string>,
providerHandleChain: { origin: string }[] = []
providerHandleChain: { origin: 'created' | 'adopted' }[] = []
): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields.
return {
return agentSessionRecordFixture({
sessionId: SESSION,
provider: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME },
providerHandleChain,
providerHandleChain: providerHandleChain.map(({ origin }, index) => ({
origin,
linkId: `link-${index}`,
mintedAtFence: 1,
observedAt: 0,
handle: claudeProviderHandle('thread-1', null)
})),
location: { executionHostId: 'local', wslDistro: null },
// A released chat reads current account configuration on its next start.
lease: { claimStatus: 'released' },
options
} as unknown as AgentSessionRecord
})
}
function exactKey(record: AgentSessionRecord): string {
@@ -172,6 +178,10 @@ function catalogDefault(store: AgentModelCatalogStore, model: string): string |
function catalogService(store: AgentModelCatalogStore, record: AgentSessionRecord) {
return createAgentModelCatalogService({
store,
getConversation: () => {
const saved = record
return saved ? agentSessionConversationView(saved) : undefined
},
getRecord: () => record,
drivesRecord: () => true,
resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }),
@@ -199,7 +209,11 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) {
deps: {
adapter: {},
agents: claudeAndCodexAgents(),
store: { getRecord: () => record },
store: {
getRecord: () => record,
getConversation: () => (record ? agentSessionConversationView(record) : null),
requireWritableRecord: () => record
},
modelCatalog
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
@@ -316,14 +330,14 @@ describe('Claude effort default at rest', () => {
it('lists an unlisted Codex model at rest exactly as its live child does', async () => {
const store = new AgentModelCatalogStore()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields.
const record = {
const record = agentSessionRecordFixture({
sessionId: SESSION,
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' },
providerHandleChain: [],
location: { executionHostId: 'local', wslDistro: null },
options: { model: 'gpt-unlisted', effort: 'high' }
} as unknown as AgentSessionRecord
})
const listing = {
models: [
{
@@ -396,14 +410,14 @@ describe('Claude effort default at rest', () => {
})
it("keeps a Codex model's effort at rest with no catalog yet, from the client's own defaults", async () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields.
const record = {
const record = agentSessionRecordFixture({
sessionId: SESSION,
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' },
providerHandleChain: [],
location: { executionHostId: 'local', wslDistro: null },
options: { model: 'gpt-5.5', effort: 'high' }
} as unknown as AgentSessionRecord
})
const resting = await readAtRest(new AgentModelCatalogStore(), record)
@@ -415,14 +429,14 @@ describe('Claude effort default at rest', () => {
it("leaves a Codex chat's unsaved effort blank at rest, as its live child does", async () => {
const store = new AgentModelCatalogStore()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields.
const record = {
const record = agentSessionRecordFixture({
sessionId: SESSION,
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' },
providerHandleChain: [],
location: { executionHostId: 'local', wslDistro: null },
options: { model: 'gpt-5.5' }
} as unknown as AgentSessionRecord
})
store.recordSuccess(
exactKey(record),
'codex',
@@ -1,3 +1,4 @@
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
import { createClaudeStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support'
import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -21,7 +22,7 @@ const IDENTITY = { sessionId: SESSION_ID } as Parameters<
>[0]['identity']
function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord {
return {
return agentSessionRecordFixture({
sessionId: SESSION_ID,
provider: 'claude',
location: {
@@ -33,7 +34,7 @@ function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/work/.claude' },
providerHandleChain: [],
...overrides
} as AgentSessionRecord
})
}
function identityAt(leafUuid: string | null): typeof IDENTITY {
@@ -153,7 +153,7 @@ async function pipeline() {
})
]
]),
getRecord: () => null,
getConversation: () => null,
now,
statusSink: () => ({
publish: (summary, subject) => {
@@ -1,3 +1,4 @@
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
import {
createCodexStructuredLaunchResolver,
TEST_SPAWN_TOKEN
@@ -41,7 +42,7 @@ async function withPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>)
}
function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord {
return {
return agentSessionRecordFixture({
sessionId: SESSION_ID,
provider: 'codex',
location: {
@@ -53,7 +54,7 @@ function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord
accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' },
providerHandleChain: [],
...overrides
} as AgentSessionRecord
})
}
function resolverFor(
+4 -1
View File
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch } = vi.hoisted(() => ({
@@ -240,7 +241,9 @@ describe('local search hits owned by a native chat', () => {
{ ...record.providerHandleChain[0]!, handle: codexProviderHandle('host-session') }
]
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership projection only reads this record-list member.
setStructuredAgentSessionHost({ deps: { store: { listRecords: () => [record] } } } as never)
setStructuredAgentSessionHost({
deps: { store: { listConversations: () => [agentSessionConversationView(record)] } }
} as never)
return { sessionId: record.sessionId, workspaceId: record.location.workspaceId }
}
@@ -12,6 +12,7 @@ import {
} from './pty/pane/spawn-reservation'
import { makePaneKey } from '../../shared/stable-pane-id'
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import {
agentSessionRecordFixture,
@@ -225,8 +226,8 @@ describe('a window view of a pane main is starting', () => {
})
)
const records: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype)
const list = vi.fn(() => [chat])
records.listRecords = list
const list = vi.fn(() => [agentSessionConversationView(chat)])
records.listConversations = list
records.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: [chat.sessionId] })
const capture = vi.spyOn(runtime, 'readSavedResumeSource').mockResolvedValue(saved)
const cleanup = vi.spyOn(runtime, 'forgetRefusedSavedResume').mockResolvedValue()
@@ -5,6 +5,7 @@ import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command'
import { registerPtyHandlers } from './pty/register-handlers'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import {
agentSessionLeaseFixture,
@@ -76,7 +77,7 @@ describe('a pane restore of a conversation a native chat continues', () => {
throw new Error('records unavailable')
}
const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype)
store.listRecords = () => [chat]
store.listConversations = () => [agentSessionConversationView(chat)]
store.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: openTabs })
return store
})
@@ -137,7 +137,7 @@ async function judgeResumes(
)
return { verdict: 'unverifiable', refused: resumes[0] }
}
const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter(
const heads = listStructuredProviderSessionOwnership(store.listConversations()).filter(
(holding) => holding.role === 'head'
)
const tabs = store.getVisibleSessionTabIndex()
@@ -145,7 +145,7 @@ async function judgeResumes(
const holders = heads.filter((holding) =>
keysOf(refused).some((key) => keysMayMatch(holding.key, key))
)
if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) {
if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.ownerObservation))) {
return { verdict: 'chat-writing', refused }
}
if (holders.length > 0 && (!tabs || !tabs.present)) {
@@ -31,6 +31,7 @@ import {
type LaunchRaceLane
} from '../pty-launch-race-test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import {
agentSessionRecordFixture,
@@ -757,7 +758,7 @@ describe('launch race parity, both spawn lanes', () => {
})
)
const records: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype)
records.listRecords = () => [chat]
records.listConversations = () => [agentSessionConversationView(chat)]
records.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: [chat.sessionId] })
const forget = vi.fn(async () => {})
const remember = vi.fn()
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view'
import { CLAUDE_CLI_CONVERSATIONS } from '../../../claude/claude-cli-conversations'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { localProvider } from '../provider/registry'
@@ -67,7 +68,7 @@ const chat = agentSessionRecordFixture(
)
const store = {
listRecords: () => [chat],
listConversations: () => [agentSessionConversationView(chat)],
getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [chat.sessionId] })
}
@@ -174,7 +175,9 @@ describe('actual runtime saved-resume outcomes', () => {
handle: { ...link.handle, nativeId: `unrelated-${index}` }
}))
}))
const list = vi.spyOn(store, 'listRecords').mockImplementation(() => records)
const list = vi
.spyOn(store, 'listConversations')
.mockImplementation(() => records.map(agentSessionConversationView))
const visible = vi.spyOn(store, 'getVisibleSessionTabIndex').mockReturnValue({
present: true,
sessionIds: records.map((record) => record.sessionId)
@@ -408,16 +411,17 @@ it('preserves case-distinct conversation heads and saved homes across the shared
}))
}
const opencodeStore: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype)
opencodeStore.listRecords = () => [record]
opencodeStore.listConversations = () => [agentSessionConversationView(record)]
opencodeStore.getVisibleSessionTabIndex = () => ({
present: true,
sessionIds: [record.sessionId]
})
const runtime = { openAgentSessionRecordStore: async () => opencodeStore }
expect(listStructuredProviderSessionOwnership([record]).map((holding) => holding.role)).toEqual([
'earlier',
'head'
])
expect(
listStructuredProviderSessionOwnership([agentSessionConversationView(record)]).map(
(holding) => holding.role
)
).toEqual(['earlier', 'head'])
for (const [index, id] of ids.entries()) {
await expect(
admitResumeSpawn(
@@ -1,6 +1,7 @@
import { agentSessionRecordFixture } from '../agent-session-record-test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionModelOption } from '../../../shared/agent-session-wire'
import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint'
import { createAgentModelCatalogService } from './agent-model-catalog-service'
@@ -12,7 +13,7 @@ import {
const HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/homes/grok' }
const RECORD = {
const RECORD = agentSessionRecordFixture({
sessionId: 'grok-session',
provider: 'grok',
accountHome: HOME,
@@ -22,7 +23,7 @@ const RECORD = {
workspaceId: 'ws-1',
workspaceKind: 'git-worktree'
}
} as const satisfies Pick<AgentSessionRecord, 'sessionId' | 'provider' | 'accountHome' | 'location'>
})
const EFFORTS = [
{ value: 'low', label: 'Low' },
@@ -54,8 +55,8 @@ function harness(probe: () => Promise<AgentModelCatalogSuccess>) {
const probes = vi.fn(probe)
const catalog = createAgentModelCatalogService({
store,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location.
getRecord: () => RECORD as unknown as AgentSessionRecord,
getConversation: () => agentSessionConversationView(RECORD),
getRecord: () => RECORD,
drivesRecord: () => true,
resolveAccountHome: async () => HOME,
probes: { grok: probes },
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { agentSessionRecordFixture } from '../agent-session-record-test-fixture'
@@ -49,6 +50,10 @@ function harness() {
const probe: AgentModelCatalogProbe = Object.assign(async () => run(), { resolve })
const service = createAgentModelCatalogService({
store,
getConversation: (id) => {
const saved = record(id, id === 'a' ? 'old' : 'another')
return saved ? agentSessionConversationView(saved) : undefined
},
getRecord: (id) => record(id, id === 'a' ? 'old' : 'another'),
drivesRecord: () => true,
resolveAccountHome: async () => HOME,
@@ -1,3 +1,5 @@
import { agentSessionRecordFixture } from '../agent-session-record-test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -45,8 +47,7 @@ function workspace(name: string, files: Record<string, string> = {}): string {
}
function record(agent: string, workspacePath: string): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only these fields.
return {
return agentSessionRecordFixture({
sessionId: `${agent}-1`,
provider: agent,
accountHome: HOMES[agent]!,
@@ -57,7 +58,7 @@ function record(agent: string, workspacePath: string): AgentSessionRecord {
workspaceId: 'ws-1',
workspaceKind: 'git-worktree'
}
} as AgentSessionRecord
})
}
async function afterNoPickChat(agent: string, workspacePath: string, picked = false) {
@@ -79,6 +80,10 @@ async function afterNoPickChat(agent: string, workspacePath: string, picked = fa
)
const catalog = createAgentModelCatalogService({
store,
getConversation: (sessionId) => {
const saved = sessionId === chat.sessionId ? chat : undefined
return saved ? agentSessionConversationView(saved) : undefined
},
getRecord: (sessionId) => (sessionId === chat.sessionId ? chat : undefined),
drivesRecord: () => true,
resolveAccountHome: async () => HOMES[agent]!,
@@ -118,6 +118,7 @@ export function refreshAgentModelCatalogListing(input: {
export function scopedAgentModelCatalogLister(input: {
legacy?: boolean
canList: () => boolean
admission: PrioritySemaphore
signal: AbortSignal
resolved: AgentModelCatalogResolvedProbe | undefined
@@ -133,6 +134,9 @@ export function scopedAgentModelCatalogLister(input: {
const timer = legacy ? setTimeout(() => deadline.abort(), 30_000) : undefined
const attemptSignal = AbortSignal.any([signal, deadline.signal])
try {
if (!input.canList()) {
throw new AgentModelCatalogListingStoppedError('catalog scope unavailable')
}
if (resolved) {
return await resolved.run(attemptSignal)
}
@@ -1,3 +1,5 @@
import { agentSessionRecordFixture } from '../agent-session-record-test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import { describe, expect, it, vi } from 'vitest'
@@ -63,6 +65,7 @@ describe('model catalog prewarm', () => {
const grok = vi.fn(async (_home: AgentSessionAccountHome) => listing('grok-4'))
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
hasChatRecords: () => true,
@@ -99,8 +102,8 @@ describe('model catalog prewarm', () => {
it("saves a running chat's reports in the order it sent them, per folder", async () => {
const store = new AgentModelCatalogStore()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome, location and launchDirectory.
const chat = {
const chat = agentSessionRecordFixture({
sessionId: 'chat-b',
provider: 'opencode2',
accountHome: HOME('OPENCODE_HOME', '/homes/a'),
@@ -110,11 +113,12 @@ describe('model catalog prewarm', () => {
workspaceId: 'ws-b',
workspaceKind: 'folder'
}
} as AgentSessionRecord
})
const lookups = [deferred<string | null>(), deferred<string | null>()]
let lookup = 0
const service = createAgentModelCatalogService({
store,
getConversation: () => agentSessionConversationView(chat),
getRecord: () => chat,
drivesRecord: () => true,
resolveAccountHome: async () => HOME('OPENCODE_HOME', '/homes/a'),
@@ -135,8 +139,8 @@ describe('model catalog prewarm', () => {
async (_home: AgentSessionAccountHome, options?: { workspacePath?: string }) =>
listing(options?.workspacePath === '/work/b' ? 'only-in-b' : 'big-pickle')
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome, location and launchDirectory.
const chatInB = {
const chatInB = agentSessionRecordFixture({
sessionId: 'chat-b',
provider: 'opencode2',
accountHome: HOME('OPENCODE_HOME', '/homes/a'),
@@ -147,9 +151,13 @@ describe('model catalog prewarm', () => {
workspaceId: 'ws-b',
workspaceKind: 'folder'
}
} as AgentSessionRecord
})
const service = createAgentModelCatalogService({
store,
getConversation: (sessionId) => {
const saved = sessionId === 'chat-b' ? chatInB : undefined
return saved ? agentSessionConversationView(saved) : undefined
},
getRecord: (sessionId) => (sessionId === 'chat-b' ? chatInB : undefined),
drivesRecord: () => true,
resolveAccountHome: async () => HOME('OPENCODE_HOME', '/homes/a'),
@@ -192,6 +200,7 @@ describe('model catalog prewarm', () => {
)
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
hasChatRecords: () => true,
@@ -226,6 +235,7 @@ describe('model catalog prewarm', () => {
)
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
hasChatRecords: () => true,
@@ -254,6 +264,7 @@ describe('model catalog prewarm', () => {
const claude = vi.fn(async (_home: AgentSessionAccountHome) => listing('opus'))
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
hasChatRecords: () => true,
@@ -287,6 +298,7 @@ describe('model catalog prewarm', () => {
store.recordSuccess('claude-other-account', 'claude', listing('opus'), 'discovery')
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`),
@@ -324,6 +336,7 @@ describe('model catalog prewarm', () => {
const hasChatRecords = vi.fn(() => true)
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`),
@@ -360,6 +373,7 @@ describe('model catalog prewarm', () => {
const agents = ['codex', 'claude', 'grok', 'opencode', 'pi']
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
hasChatRecords: () => true,
@@ -378,8 +392,7 @@ describe('model catalog prewarm', () => {
describe('one account, one catalog key', () => {
function record(path: string): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key reads only provider, accountHome and location.
return {
return agentSessionRecordFixture({
sessionId: 'session-1',
provider: 'grok',
accountHome: HOME('GROK_HOME', path),
@@ -389,7 +402,7 @@ describe('one account, one catalog key', () => {
workspaceId: 'ws-1',
workspaceKind: 'git-worktree'
}
} as AgentSessionRecord
})
}
it('a session’s saves through the host and through its adapter’s handle land under its record’s key', async () => {
@@ -398,6 +411,7 @@ describe('one account, one catalog key', () => {
const launched = record(managedHome)
const service = createAgentModelCatalogService({
store,
getConversation: () => agentSessionConversationView(launched),
getRecord: () => launched,
drivesRecord: () => true,
resolveAccountHome: async () => HOME('GROK_HOME', managedHome)
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import {
@@ -38,6 +39,7 @@ function rig(agent = 'grok') {
)
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => HOME,
@@ -69,11 +71,16 @@ describe('a saved-only catalog read', () => {
}
const store = new AgentModelCatalogStore({ now: () => now })
const probe = vi.fn(async () => listing('grok-4.7'))
const resolveAccountHome = vi.fn(async () => HOME)
const service = createAgentModelCatalogService({
store,
getRecord: (sessionId) => (sessionId === record.sessionId ? record : undefined),
getConversation: (sessionId) => {
const saved = sessionId === record.sessionId ? record : undefined
return saved ? agentSessionConversationView(saved) : undefined
},
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => HOME,
resolveAccountHome,
probes: { grok: probe }
})
@@ -92,6 +99,7 @@ describe('a saved-only catalog read', () => {
})
expect(probe).not.toHaveBeenCalled()
expect(resolveAccountHome).not.toHaveBeenCalled()
await service.read({ agent: 'grok' })
expect(probe).toHaveBeenCalledOnce()
})
@@ -101,6 +101,7 @@ describe('a picker read with the CLI missing', () => {
let now = 1_000
const service = createAgentModelCatalogService({
store: new AgentModelCatalogStore({ now: () => now }),
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: root }),
@@ -1,8 +1,5 @@
import { homedir } from 'node:os'
import type {
AgentSessionAccountHome,
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-record'
import { isNativeLocalLocation } from '../../../shared/execution-host'
import type {
AgentModelCatalogService,
@@ -14,13 +11,13 @@ import {
type AgentModelCatalogLookup,
type AgentModelCatalogResolvedProbe
} from './agent-model-catalog-discovery'
import { sessionWorkspacePath } from './agent-model-catalog-workspace'
import { sessionWorkspacePath, type AgentModelCatalogRecord } from './agent-model-catalog-workspace'
/** Discovery follows launch configuration, never session picks. */
export async function resolveAgentModelCatalogScope(
deps: AgentModelCatalogServiceDeps,
params: Parameters<AgentModelCatalogService['read']>[0],
record: AgentSessionRecord | undefined,
record: AgentModelCatalogRecord | undefined,
base: string,
home: AgentSessionAccountHome | null
): Promise<{
@@ -37,10 +34,11 @@ export async function resolveAgentModelCatalogScope(
return { base, fingerprint: base, exact: false }
}
const probe = deps.probes?.[params.agent]
const savedOnly = () => params.savedOnly || !!(record && !deps.getRecord(record.sessionId))
if (params.legacy) {
const fingerprint = agentModelCatalogScopeFingerprint(base, 'legacy-account')
const resolved =
params.savedOnly || !deps.store.probeDue(fingerprint)
savedOnly() || !deps.store.probeDue(fingerprint)
? null
: await probe?.resolveLegacy?.(home).catch(() => null)
return {
@@ -61,9 +59,9 @@ export async function resolveAgentModelCatalogScope(
probe?.resolve?.(home, cwd, lookup).catch(() => null)
const scopeKey = (resolved: AgentModelCatalogResolvedProbe | null | undefined) =>
agentModelCatalogScopeFingerprint(base, cwd, resolved?.identity)
let resolved = await resolve(params.savedOnly ? 'saved' : 'snapshot')
let resolved = await resolve(savedOnly() ? 'saved' : 'snapshot')
// Rerun shells only for reads due to list; saved-only reads must remain passive.
if (!params.savedOnly && (!resolved || deps.store.probeDue(scopeKey(resolved)))) {
if (!savedOnly() && (!resolved || deps.store.probeDue(scopeKey(resolved)))) {
resolved = await resolve('recover')
}
if (probe?.resolve && !resolved) {
@@ -8,11 +8,16 @@ import type {
AgentModelCatalogLiveListing,
AgentModelCatalogProbe
} from './agent-model-catalog-store'
import type { AgentModelCatalogWorkspaceDeps } from './agent-model-catalog-workspace'
import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import type {
AgentModelCatalogWorkspaceDeps,
AgentModelCatalogRecord
} from './agent-model-catalog-workspace'
export type AgentModelCatalogServiceDeps = AgentModelCatalogWorkspaceDeps & {
getConversation: (sessionId: string) => AgentSessionConversationView | undefined
getRecord: (sessionId: string) => AgentSessionRecord | undefined
/** Unlaunchable records cannot authorize probing their account with a different host binary. */
drivesRecord: (record: AgentSessionRecord) => boolean
drivesRecord: (record: AgentModelCatalogRecord) => boolean
/** Share the launch resolver so recordless reads use the account a new chat would pin. */
resolveAccountHome: (agent: string) => Promise<AgentSessionAccountHome>
probes?: Readonly<Partial<Record<string, AgentModelCatalogProbe>>>
@@ -1,3 +1,5 @@
import { agentSessionRecordFixture } from '../agent-session-record-test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import {
joinHostModelListingWait,
isHostModelListingWaitInFlight
@@ -17,8 +19,7 @@ import {
} from './agent-model-catalog-store'
function record(accountHomePath: string): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location; the rest of the record is irrelevant here.
return {
return agentSessionRecordFixture({
sessionId: 'session-1',
provider: 'grok',
accountHome: { variable: 'GROK_HOME', path: accountHomePath },
@@ -28,7 +29,7 @@ function record(accountHomePath: string): AgentSessionRecord {
workspaceId: 'ws-1',
workspaceKind: 'git-worktree'
}
} as AgentSessionRecord
})
}
function listing(id: string): AgentModelCatalogSuccess {
@@ -58,6 +59,7 @@ describe('agent model catalog service', () => {
const probe = vi.fn(async (_home: AgentSessionAccountHome) => listing('gpt-a'))
const service = createAgentModelCatalogService({
store,
getConversation: () => agentSessionConversationView(record('/homes/a')),
getRecord: () => record('/homes/a'),
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected'),
@@ -85,6 +87,7 @@ describe('agent model catalog service', () => {
const drivesRecord = vi.fn(() => false)
const service = createAgentModelCatalogService({
store,
getConversation: () => agentSessionConversationView(pinned),
getRecord: () => pinned,
drivesRecord,
resolveAccountHome: async () => GROK_HOME('/homes/selected'),
@@ -93,7 +96,7 @@ describe('agent model catalog service', () => {
await service.read({ agent: 'grok', sessionId: 'session-1' })
expect(drivesRecord).toHaveBeenCalledWith(pinned)
expect(drivesRecord).toHaveBeenCalledWith(agentSessionConversationView(pinned))
expect(probe).toHaveBeenCalledExactlyOnceWith(GROK_HOME('/homes/selected'), {
signal: expect.any(AbortSignal)
})
@@ -106,6 +109,7 @@ describe('agent model catalog service', () => {
const probe = vi.fn(async () => listing('gpt-new'))
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/new'),
@@ -142,6 +146,7 @@ describe('agent model catalog service', () => {
)
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected')
@@ -167,6 +172,7 @@ describe('agent model catalog service', () => {
)
const service = createAgentModelCatalogService({
store,
getConversation: () => agentSessionConversationView(sessionRecord),
getRecord: () => sessionRecord,
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected')
@@ -182,6 +188,7 @@ describe('agent model catalog service', () => {
})
const service = createAgentModelCatalogService({
store,
getConversation: () => agentSessionConversationView(record('/homes/a')),
getRecord: () => record('/homes/a'),
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/a'),
@@ -203,6 +210,7 @@ describe('agent model catalog service', () => {
const probe = vi.fn(async () => listing('gpt-a'))
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => {
@@ -231,6 +239,7 @@ describe('agent model catalog service', () => {
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected'),
@@ -428,6 +437,7 @@ describe('agent model catalog service', () => {
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected')
@@ -450,6 +460,7 @@ describe('agent model catalog service', () => {
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected'),
@@ -473,6 +484,7 @@ describe('agent model catalog service', () => {
const workspaceMayOverrideDefaultModel = vi.fn(async () => mayOverride)
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => GROK_HOME('/homes/selected'),
@@ -540,6 +552,7 @@ describe('agent model catalog service', () => {
const probe = vi.fn(async (home: AgentSessionAccountHome) => listing(JSON.stringify(home)))
const service = createAgentModelCatalogService({
store,
getConversation: () => agentSessionConversationView(openCodeRecord(managed(PROFILE_A))),
getRecord: () => openCodeRecord(managed(PROFILE_A)),
drivesRecord: () => true,
resolveAccountHome: async () => managed(PROFILE_B),
@@ -561,6 +574,8 @@ describe('agent model catalog service', () => {
const probe = vi.fn(async () => listing('never'))
const service = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
getConversation: () =>
agentSessionConversationView(openCodeRecord(managed(PROFILE_A), 'Ubuntu')),
getRecord: () => openCodeRecord(managed(PROFILE_A), 'Ubuntu'),
drivesRecord: () => true,
resolveAccountHome: async () => managed(PROFILE_B),
@@ -609,6 +624,7 @@ it.each(['cold', 'warm', 'failure', 'stop'] as const)(
)
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/account' }),
@@ -4,24 +4,19 @@ import {
} from './agent-model-catalog-lister'
import { prewarmAgentModelCatalog, newChatCatalogKey } from './agent-model-catalog-prewarm'
import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire'
import type {
AgentSessionAccountHome,
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-record'
import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint'
import {
agentModelCatalogResultFromEntry,
retainedAgentModelCatalogModels
} from './agent-model-catalog-entry'
import type {
AgentModelCatalogEntry,
AgentModelCatalogLiveListing
} from './agent-model-catalog-store'
import type { AgentModelCatalogEntry } from './agent-model-catalog-store'
import {
catalogFolder,
recordConfiguredDefault,
createAgentModelCatalogLiveListingRecorder,
sessionWorkspacePath,
type AgentModelCatalogRecord,
workspaceKeepsListedDefault
} from './agent-model-catalog-workspace'
@@ -47,36 +42,12 @@ export function createAgentModelCatalogService(
const listWith = agentModelCatalogProbeLister(lifetime.signal)
// Serialize folder-scoped writes across asynchronous folder lookups.
const liveWrites = new Map<string, Promise<void>>()
const sessionCatalogKey = async (record: AgentSessionRecord): Promise<string> =>
const sessionCatalogKey = async (record: AgentModelCatalogRecord): Promise<string> =>
agentModelCatalogFingerprintForRecord(
record,
await catalogFolder(deps, record.provider, undefined, record)
)
const saveLiveListing = (
record: AgentSessionRecord,
listing: AgentModelCatalogLiveListing,
fingerprint: string
): void => {
const { configuredDefault, ...listed } = listing
const saved = deps.store.recordSuccess(
fingerprint,
record.provider,
{ ...listed, origin: 'live-session' },
'live'
)
if (saved && configuredDefault !== undefined) {
// Preserve report order when learning configured defaults.
const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve())
.then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault))
.catch(() => {})
.then(() => {
if (recordingDefaults.get(fingerprint) === recording) {
recordingDefaults.delete(fingerprint)
}
})
recordingDefaults.set(fingerprint, recording)
}
}
const saveLiveListing = createAgentModelCatalogLiveListingRecorder(deps, recordingDefaults)
return {
peek(sessionId, verifiedOnly) {
const key = sessionKeys.get(sessionId)
@@ -93,11 +64,16 @@ export function createAgentModelCatalogService(
return
}
void sessionCatalogKey(record)
.then((fingerprint) => deps.store.expireFailure(fingerprint))
.then((fingerprint) => {
if (deps.getRecord(record.sessionId)?.lease.runtimeFence === record.lease.runtimeFence) {
deps.store.expireFailure(fingerprint)
}
})
.catch(() => {})
},
async read(params) {
const record = params.sessionId ? deps.getRecord(params.sessionId) : undefined
const record = params.sessionId ? deps.getConversation(params.sessionId) : undefined
const savedOnly = params.savedOnly || !!(record && !deps.getRecord(record.sessionId))
const scoped =
record && record.provider === params.agent && deps.drivesRecord(record) ? record : undefined
const workspacePath = params.inSessionWorkspace
@@ -125,7 +101,7 @@ export function createAgentModelCatalogService(
}
const scope = await resolveAgentModelCatalogScope(
deps,
params,
{ ...params, savedOnly },
scoped,
fingerprint,
probeHome
@@ -152,6 +128,7 @@ export function createAgentModelCatalogService(
const probe = deps.probes?.[params.agent]
const list = scopedAgentModelCatalogLister({
legacy: params.legacy,
canList: () => !record || !!deps.getRecord(record.sessionId),
admission,
signal: lifetime.signal,
resolved,
@@ -221,8 +198,11 @@ export function createAgentModelCatalogService(
}
// Observed process exit suppresses background relaunch; explicit new chats may discover.
if (
params.savedOnly ||
(!exact && scoped?.lease?.claimStatus === 'released' && scoped.lease.deathEvidence)
savedOnly ||
(scoped && !deps.getRecord(scoped.sessionId)) ||
(!exact &&
scoped?.ownerObservation.claimStatus === 'released' &&
scoped.ownerObservation.deathEvidence)
) {
return answer(entry)
}
@@ -268,7 +248,7 @@ export function createAgentModelCatalogService(
return answer(entry)
},
async retainedLiveModelIds(sessionId, listing) {
const record = deps.getRecord(sessionId)
const record = deps.getConversation(sessionId)
const entry =
record && deps.drivesRecord(record)
? deps.store.get(await sessionCatalogKey(record))
@@ -286,7 +266,12 @@ export function createAgentModelCatalogService(
}
// Serialize reports so a slow folder lookup cannot overwrite a newer listing.
const writing = (liveWrites.get(sessionId) ?? Promise.resolve())
.then(async () => saveLiveListing(record, listing, await sessionCatalogKey(record)))
.then(async () => {
const fingerprint = await sessionCatalogKey(record)
if (deps.getRecord(sessionId)?.lease.runtimeFence === record.lease.runtimeFence) {
saveLiveListing(record, listing, fingerprint)
}
})
.catch((error: unknown) => {
console.warn('[agent-model-catalog] could not save a chat model list:', error)
})
@@ -57,6 +57,7 @@ function rig(initialProbe: AgentModelCatalogProbe = signedOut) {
)
const service = createAgentModelCatalogService({
store,
getConversation: () => undefined,
getRecord: () => undefined,
drivesRecord: () => true,
resolveAccountHome: async () => ACCOUNT_HOME,
@@ -1,14 +1,22 @@
import type {
AgentModelCatalogLiveListing,
AgentModelCatalogStore
} from './agent-model-catalog-store'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import { isNativeLocalLocation } from '../../../shared/execution-host'
import type { AgentModelCatalogConfiguredChoice } from './agent-model-catalog-entry'
import type { AgentModelCatalogStore } from './agent-model-catalog-store'
export type AgentModelCatalogRecord = Pick<
AgentSessionRecord,
'sessionId' | 'provider' | 'providerHandleChain' | 'accountHome' | 'location' | 'launchDirectory'
>
export type AgentModelCatalogWorkspaceDeps = {
store: AgentModelCatalogStore
/** Project-scoped catalogs require a folder and cannot be prewarmed. */
listsInChatFolder?: ReadonlySet<string>
recordWorkspacePath?: (record: AgentSessionRecord) => Promise<string | null>
recordWorkspacePath?: (record: AgentModelCatalogRecord) => Promise<string | null>
/** Account-home config is already covered by discovery; only workspace layers can override it. */
workspaceMayOverrideDefaultModel?: (input: {
agent: string
@@ -39,7 +47,7 @@ export async function workspaceKeepsListedDefault(
/** null means the host cannot inspect this session's workspace. */
export async function sessionWorkspacePath(
deps: AgentModelCatalogWorkspaceDeps,
record: AgentSessionRecord
record: AgentModelCatalogRecord
): Promise<string | null> {
if (!isNativeLocalLocation(record.location) || !deps.recordWorkspacePath) {
return null
@@ -52,7 +60,7 @@ export async function catalogFolder(
deps: AgentModelCatalogWorkspaceDeps,
agent: string,
workspacePath: string | null | undefined,
session: AgentSessionRecord | undefined
session: AgentModelCatalogRecord | undefined
): Promise<string | undefined> {
if (!deps.listsInChatFolder?.has(agent)) {
return undefined
@@ -66,7 +74,9 @@ export async function catalogFolder(
/** Learn account defaults only without workspace overrides; null resolution retires stale facts. */
export async function recordConfiguredDefault(
deps: AgentModelCatalogWorkspaceDeps,
deps: AgentModelCatalogWorkspaceDeps & {
getRecord: (id: string) => AgentSessionRecord | undefined
},
record: AgentSessionRecord,
fingerprint: string,
choice: AgentModelCatalogConfiguredChoice | null
@@ -86,5 +96,39 @@ export async function recordConfiguredDefault(
) {
return
}
if (deps.getRecord(record.sessionId)?.lease.runtimeFence !== record.lease.runtimeFence) {
return
}
deps.store.recordConfiguredDefault(fingerprint, choice)
}
export function createAgentModelCatalogLiveListingRecorder(
deps: Parameters<typeof recordConfiguredDefault>[0],
recordingDefaults: Map<string, Promise<void>>
) {
return (
record: AgentSessionRecord,
listing: AgentModelCatalogLiveListing,
fingerprint: string
): void => {
const { configuredDefault, ...listed } = listing
const saved = deps.store.recordSuccess(
fingerprint,
record.provider,
{ ...listed, origin: 'live-session' },
'live'
)
if (saved && configuredDefault !== undefined) {
// Preserve report order when learning configured defaults.
const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve())
.then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault))
.catch(() => {})
.then(() => {
if (recordingDefaults.get(fingerprint) === recording) {
recordingDefaults.delete(fingerprint)
}
})
recordingDefaults.set(fingerprint, recording)
}
}
}
@@ -10,6 +10,7 @@ import {
import { argumentProblemOf } from '../structured-agent-arguments-error'
import {
agentSessionRefusalFromReference,
isAgentSessionRefusalError,
readAgentSessionRefusalReference
} from '../../../shared/agent-session-wire-refusals'
import type {
@@ -67,6 +68,14 @@ export function claimStructuredAgentSessionAgent(
sessionId: string,
startedFor?: string
): StructuredAgentSessionStartClaim {
try {
context.deps.store.requireWritableRecord(sessionId)
} catch (error) {
if (isAgentSessionRefusalError(error)) {
return { outcome: Promise.resolve({ ok: false, refusal: error.refusal }) }
}
throw error
}
const session = context.sessions.get(sessionId)
const child = session?.child
if (!structuredAgentSessionStartStillQueued(session, startedFor)) {
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
@@ -12,7 +13,7 @@ export function openTestAttachConversation(
(
await openStructuredAgentSessionConversationJournal(
{ journalDatabase, logger: recordingStructuredAgentSessionLogger().logger },
record,
agentSessionConversationView(record),
{
acquisition: true
}
@@ -128,7 +128,7 @@ export function admitAttachOrRefuse(
}
export function journalIdentityFor(
record: AgentSessionRecord,
record: Pick<AgentSessionRecord, 'sessionId' | 'providerHandleChain' | 'providerContextBoundary'>,
params: AgentSessionAttachParams
): AgentSessionJournalIdentity {
const active = activeProviderContext(record)
@@ -37,7 +37,7 @@ function channelOver(
const channel = new StructuredAgentSessionBackgroundTaskChannel(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the channel reads only the record store's fence and the adapter's stop capability.
{
store: { getRecord: () => null },
store: { getConversation: () => null },
adapter: { backgroundTaskStops: stops }
} as unknown as StructuredAgentSessionHostDeps,
sessions,
@@ -48,7 +48,7 @@ export class StructuredAgentSessionBackgroundTaskChannel {
)
const result = readStructuredAgentSessionHistoryResult({
journal,
record: this.deps.store.getRecord(request.sessionId),
record: this.deps.store.getConversation(request.sessionId),
request,
scope,
replaceNormalizedHistory,
@@ -93,6 +93,7 @@ function closeProviderRoot(
if (!adapter.disposeSession && !adapter.closeSession) {
return Promise.resolve(true)
}
context.deps.store.requireWritableRecord(sessionId)
return stopAgentSessionProviderRoot(
async () => {
const close = adapter.disposeSession ?? adapter.closeSession
@@ -411,7 +411,7 @@ export function registerCodexInputFinalization(current: typeof codexInputFinaliz
if (result === 'new-session') {
const opened = await openStructuredAgentSessionConversationJournal(
host['deps'],
host['deps'].store.getRecord(SESSION)!
host['deps'].store.getConversation(SESSION)!
)
sessions.set(SESSION, opened.session)
currentJournal = opened.session.journal
@@ -19,7 +19,10 @@ import {
} from './structured-agent-session-mutation-refusals'
import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission'
import type { MutationCommandReceipt } from './structured-agent-session-mutation-plans'
import { mutationTurnContext } from './structured-agent-session-mutation-turn-context'
import {
mutationTurnContext,
mutationReplayContext
} from './structured-agent-session-mutation-turn-context'
import { runCommandReceiptMutation } from './structured-agent-session-command-receipt'
import {
agentSessionOperationOutcomeUnknown,
@@ -42,8 +45,7 @@ export async function admitCommandReceiptMutation<TValue>(
})
)
}
const record = store.getRecord(envelope.sessionId)
if (!record) {
if (store.selectWritableRecord(envelope.sessionId).kind === 'missing') {
return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED)
}
let read
@@ -64,15 +66,19 @@ export async function admitCommandReceiptMutation<TValue>(
if (read.verdict !== 'absent') {
return answerCommandReceipt(request, hostFingerprint, read)
}
const prepared = await request.prepareSession?.('admit', record)
store.requireWritableRecord(envelope.sessionId)
const prepared = await request.prepareSession?.(
'admit',
store.getConversation(envelope.sessionId)!
)
if (prepared && !prepared.ok) {
return prepared
}
const journal = request.journal()
const current = store.getRecord(envelope.sessionId)
if (!journal || !current) {
if (!journal || store.selectWritableRecord(envelope.sessionId).kind === 'missing') {
return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED)
}
const current = store.requireWritableRecord(envelope.sessionId)
// Journal fences protect conversation writes; provider effects also require the lease.
const leaseRefusal = plan.conversationWrite ? null : refuseUnlessWriterAdmitted(current.lease)
if (leaseRefusal) {
@@ -143,7 +149,7 @@ async function answerCommandReceipt<TValue>(
)
)
}
const record = request.store.getRecord(envelope.sessionId)
const record = request.store.getConversation(envelope.sessionId)
if (!record) {
return unknown(request)
}
@@ -152,11 +158,11 @@ async function answerCommandReceipt<TValue>(
return prepared
}
const journal = request.journal()
const current = request.store.getRecord(envelope.sessionId)
const current = request.store.getConversation(envelope.sessionId)
if (!journal || !current) {
return unknown(request)
}
const context = mutationTurnContext(request, journal, current)
const context = mutationReplayContext(request, journal, current)
const outcome = commandReceiptOutcome(receipt)
const replay = resolveAgentSessionReplayOutcome({
operationId: envelope.clientOperationId,
@@ -80,7 +80,7 @@ export function createStructuredAgentSessionConversationLifetime(
const closeContext = { sessions, serialize, closeConversation, deps }
const hasOpenDispatch = (sessionId: string): boolean => {
const record = deps().store.getRecord(sessionId)
const record = deps().store.getConversation(sessionId)
return record !== null && deps().hasOpenDispatch?.(record) === true
}
const providerHoldsDispatch = (sessionId: string): boolean =>
@@ -118,12 +118,12 @@ export function createStructuredAgentSessionConversationLifetime(
executionHostId: ExecutionHostId
): Promise<WorkspaceSleepCloseResult | null> => {
const matches = (): boolean => {
const location = deps().store.getRecord(sessionId)?.location
const location = deps().store.getConversation(sessionId)?.location
return location?.workspaceId === workspaceId && location.executionHostId === executionHostId
}
const starts = workspaceSleepStartAborts(() => host.context().runtimeState, sessionId)
// Pending creates have no record yet; their queued close waits for founding.
if (matches()) {
if (matches() && deps().store.getRecord(sessionId)) {
const journal = sessions.get(sessionId)?.journal
if (journal) {
closeStructuredAgentSessionHandovers(journal)
@@ -135,13 +135,15 @@ export function createStructuredAgentSessionConversationLifetime(
return null
}
readRefusals.forget(sessionId)
starts.abort()
const record = deps().store.getRecord(sessionId)
const record = deps().store.getConversation(sessionId)
if (!record) {
return null
}
const fence = record.lease.runtimeFence
const fence = record.currentFence
const admittedSends = deps().store.admittedSendCount(sessionId)
if (deps().store.getRecord(sessionId)) {
starts.abort()
}
const session = sessions.get(sessionId)
const child = session?.child
const owned = deps().adapter.hasOwnedAcquisition?.(sessionId) === true
@@ -167,7 +169,7 @@ export function createStructuredAgentSessionConversationLifetime(
)
// Return the post-release fence for checks against a later restart.
return {
fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence,
fence: deps().store.getConversation(sessionId)?.currentFence ?? fence,
stopped,
admittedSends
}
@@ -183,7 +185,7 @@ export function createStructuredAgentSessionConversationLifetime(
deliveryActive: host.deliveryActive,
childWorkOpen: host.childWorkOpen,
store: {
getRecord: (sessionId) => deps().store.getRecord(sessionId),
getConversation: (sessionId) => deps().store.getConversation(sessionId),
replacedRuntime: (sessionId) => deps().store.replacedRuntime(sessionId)
},
hasOpenDispatch,
@@ -274,7 +276,7 @@ export function createStructuredAgentSessionConversationLifetime(
readRefusals.forget(sessionId)
return open
}
const record = deps().store.getRecord(sessionId)
const record = deps().store.getConversation(sessionId)
if (!record) {
throw agentSessionRefusalError('agent_session_identity_required', {
reason: 'recordMissing'
@@ -304,10 +306,14 @@ export function createStructuredAgentSessionConversationLifetime(
closeStructuredAgentSessionHandovers(journal)
}
// Abort outside serialization so hung startup cannot block close.
host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting')
if (deps().store.selectWritableRecord(sessionId).kind !== 'unreadable') {
host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting')
}
return serialize(sessionId, async () => {
readRefusals.forget(sessionId)
host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting')
if (deps().store.selectWritableRecord(sessionId).kind !== 'unreadable') {
host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting')
}
await holdStopClose(sessionId, () => stopAgent(sessionId, { cause }))
})
}
@@ -8,7 +8,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store
import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import {
attachFingerprintFields,
journalIdentityFor,
@@ -28,7 +28,7 @@ export type OpenedStructuredAgentSessionConversation = {
}
export type StructuredAgentSessionConversationOpenDeps = {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'replacedRuntime'>
store: Pick<AgentSessionRecordStore, 'getConversation' | 'replacedRuntime'>
journalDatabase: JournalHostDatabase
logger: StructuredAgentSessionHostDeps['logger']
}
@@ -57,7 +57,7 @@ export async function openStructuredAgentSessionConversation(
if (open) {
return open
}
const record = context.deps.store.getRecord(sessionId)
const record = context.deps.store.getConversation(sessionId)
if (!record) {
return null
}
@@ -69,22 +69,22 @@ export async function openStructuredAgentSessionConversation(
/** Caller adopts the unindexed journal; settlement needs store ownership history. */
export async function openStructuredAgentSessionConversationJournal(
deps: StructuredAgentSessionConversationOpenDeps,
record: AgentSessionRecord,
record: AgentSessionConversationView,
options?: StructuredAgentSessionConversationOpenOptions
): Promise<OpenedStructuredAgentSessionConversation>
export async function openStructuredAgentSessionConversationJournal(
deps: Omit<StructuredAgentSessionConversationOpenDeps, 'store'>,
record: AgentSessionRecord,
record: AgentSessionConversationView,
options: { acquisition: true }
): Promise<OpenedStructuredAgentSessionConversation>
export async function openStructuredAgentSessionConversationJournal(
deps: Omit<StructuredAgentSessionConversationOpenDeps, 'store'> &
Partial<Pick<StructuredAgentSessionConversationOpenDeps, 'store'>>,
record: AgentSessionRecord,
record: AgentSessionConversationView,
options: StructuredAgentSessionConversationOpenOptions = {}
): Promise<OpenedStructuredAgentSessionConversation> {
const { sessionId } = record
const fence = record.lease.runtimeFence
const fence = record.currentFence
const params = attachParamsForRecord(record, {
clientOperationId: `read-restore:${sessionId}`,
expectedRuntimeFence: fence
@@ -127,8 +127,8 @@ export async function resettleOpenStructuredAgentSessionConversation(
sessionId: string,
session: StructuredAgentSessionHostSession | undefined
): Promise<void> {
const record = deps.store.getRecord(sessionId)
if (session && record?.lease.deathEvidence) {
const record = deps.store.getConversation(sessionId)
if (session && record?.ownerObservation.deathEvidence) {
await settleGoneGeneration(deps, record, session.journal)
}
}
@@ -136,16 +136,16 @@ export async function resettleOpenStructuredAgentSessionConversation(
async function settleGoneGeneration(
deps: Pick<StructuredAgentSessionConversationOpenDeps, 'logger'> &
Partial<Pick<StructuredAgentSessionConversationOpenDeps, 'store'>>,
record: AgentSessionRecord,
record: AgentSessionConversationView,
journal: AgentSessionJournal
): Promise<void> {
try {
await settleStaleStructuredAgentSessionState({
journal,
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
fence: record.currentFence,
acquisitionGeneration: null,
deathEvidence: record.lease.deathEvidence ?? null,
deathEvidence: record.ownerObservation.deathEvidence ?? null,
replaced: deps.store?.replacedRuntime(record.sessionId),
failureTextContext: structuredAgentSessionFailureWordsContext(record)
})
@@ -160,7 +160,7 @@ async function settleGoneGeneration(
}
export function attachParamsForRecord(
record: AgentSessionRecord,
record: Pick<AgentSessionConversationView, 'sessionId' | 'location' | 'provider' | 'accountHome'>,
input: {
clientOperationId: string
expectedRuntimeFence: number
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree'
import { isAgentSessionCreateAdmissionRefusal } from '../../../shared/agent-session-definitive-refusal'
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
@@ -168,16 +169,17 @@ async function foundConversation(
const existing = context.sessions.get(sessionId)
const opened = existing
? { session: existing }
: await openStructuredAgentSessionConversationJournal(deps, candidate.record).catch(
(error: unknown) => {
deps.logger.warn('opening the journal of a new chat failed', {
scope: 'create-journal-open',
sessionId,
error
})
throw journalOpenRefusalError(error)
}
)
: await openStructuredAgentSessionConversationJournal(
deps,
agentSessionConversationView(candidate.record)
).catch((error: unknown) => {
deps.logger.warn('opening the journal of a new chat failed', {
scope: 'create-journal-open',
sessionId,
error
})
throw journalOpenRefusalError(error)
})
const { session } = opened
const fence = candidate.record.lease.runtimeFence
let committed = replayed
@@ -1,3 +1,4 @@
import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { isHistoricalProviderTimelineItem } from '../agent-session-timeline/provider-timeline-history'
import { currentRequestsProjection } from './agent-session-current-requests'
// Derive operational work from lease, first-hand exit, replaced-runtime and journal evidence.
@@ -29,7 +30,7 @@ export type StructuredAgentSessionCurrentWorkJournal = Pick<
>
export type StructuredAgentSessionWorkEvidence = {
record: Pick<AgentSessionRecord, 'lease'> | null
record: Pick<AgentSessionConversationView, 'ownerObservation' | 'currentFence'> | null
ended?: StructuredAgentSessionSeenEnd
revision?: number
replaced?: StructuredAgentSessionReplacedEnd | undefined
@@ -50,7 +51,9 @@ type StructuredAgentSessionSeenEnd = {
export function structuredAgentSessionLiveFence(
evidence: StructuredAgentSessionWorkEvidence
): number | null {
const lease = evidence.record?.lease
const record = evidence.record
const lease = record?.ownerObservation
const fence = record?.currentFence
if (!lease) {
return 0
}
@@ -58,22 +61,27 @@ export function structuredAgentSessionLiveFence(
return null
}
if (
structuredAgentSessionReplacedRuntimeEnded(lease, evidence.replaced) ||
structuredAgentSessionReplacedRuntimeEnded(
{ runtimeFence: fence ?? 0, claimStatus: lease.claimStatus },
evidence.replaced
) ||
structuredAgentSessionOwnerUnverifiable(evidence)
) {
return null
}
const { ended } = evidence
return ended?.rootGone === true && ended.fence === lease.runtimeFence ? null : lease.runtimeFence
return ended?.rootGone === true && ended.fence === fence ? null : (fence ?? 0)
}
/** Unreconciled/recovering owners without a local child remain unverifiable, not current work. */
function structuredAgentSessionOwnerUnverifiable(evidence: StructuredAgentSessionWorkEvidence) {
const lease = evidence.record?.lease
const record = evidence.record
const lease = record?.ownerObservation
const fence = record?.currentFence
return (
(lease?.unreconciled === true || lease?.handoffStage === 'recovering') &&
evidence.child !== undefined &&
evidence.child?.fence !== lease.runtimeFence
evidence.child?.fence !== fence
)
}
@@ -0,0 +1,239 @@
import {
composeStructuredLaunch,
composeStructuredLaunchRead
} from '../../runtime/structured-agent-launch-composition'
import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition'
import { afterEach, expect, it, vi } from 'vitest'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission'
import { setOptionPlan } from './structured-agent-session-mutation-plans'
import {
createQueuedMessageTestRig,
QUEUED_RIG_CALLER as CALLER,
type QueuedMessageTestRig
} from './structured-agent-session-queued-message-rig.test-fixture'
import {
HOST_TEST_SESSION as SESSION,
hostTestMessage,
hostTestOperationId
} from './structured-agent-session-host-test-data'
let rig: QueuedMessageTestRig | undefined
let restored: StructuredAgentSessionHost | undefined
afterEach(async () => {
await restored?.flushAllStreamedEvents()
await rig?.dispose()
})
it('replays a saved send and its conflict but refuses fresh send, Stop and metadata before any new work', async () => {
rig = await createQueuedMessageTestRig({ restartable: true })
await rig.store.replaceSessionOptions({
sessionId: SESSION,
fence: 1,
options: { model: 'kept-model', effort: 'high' },
now: 1800000000000
})
const body = hostTestMessage('saved message')
const envelope = rig.envelope({ body }, 'agentSession.send', hostTestOperationId())
const params = { envelope, body, userSend: true as const }
const accepted = await rig.host.send(CALLER, params)
expect(accepted).toMatchObject({ ok: true })
expect(
rig.store.readCommandReceipt({ kind: 'global' }, envelope.clientOperationId)
).toMatchObject({ verdict: 'readable' })
await rig.host.flushAllStreamedEvents()
damageFloor(rig.root)
const store = await openTestAgentSessionRecordStore(rig.root)
expect(store.readCommandReceipt({ kind: 'global' }, envelope.clientOperationId)).toMatchObject({
verdict: 'readable'
})
restored = new StructuredAgentSessionHost({ ...rig.host.deps, store })
expect(await restored.readOptions(SESSION, { readsWithoutModel: true })).toMatchObject({
current: { model: 'kept-model', effort: 'high' }
})
const starts = rig.starts.mock.calls.length
const sends = rig.dispatch.mock.calls.length
expect(await restored.revealSession(SESSION)).toMatchObject({ sessionId: SESSION })
expect(await restored.send(CALLER, params)).toMatchObject({
ok: true,
replayed: true,
fence: store.getConversation(SESSION)?.currentFence,
value: { clientMessageId: envelope.clientOperationId }
})
const changed = hostTestMessage('different message')
expect(
await restored.send(CALLER, {
...params,
body: changed,
envelope: rig.envelope({ body: changed }, 'agentSession.send', envelope.clientOperationId)
})
).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } })
expect(
await restored.send(CALLER, {
...params,
envelope: rig.envelope({ body }, 'agentSession.send', hostTestOperationId())
})
).toMatchObject({ ok: false, refusal: { details: { reason: 'recordUnreadable' } } })
expect(
await restored.cancel(CALLER, {
envelope: rig.envelope({}, 'agentSession.cancel', hostTestOperationId())
})
).toMatchObject({ ok: false, refusal: { details: { reason: 'recordUnreadable' } } })
await expect(store.setConversationName(SESSION, 'changed')).rejects.toMatchObject({
refusal: { details: { reason: 'recordUnreadable' } }
})
expect(rig.starts).toHaveBeenCalledTimes(starts)
expect(rig.dispatch).toHaveBeenCalledTimes(sends)
expect((await restored.journalSnapshot(SESSION)).submissions).toHaveLength(1)
const close = vi.spyOn(restored.deps.adapter, 'closeSession')
const release = vi.spyOn(restored.deps.adapter, 'releaseAcquisition')
const closes = close.mock.calls.length
const releases = release.mock.calls.length
expect(await restored.sleepWorkspace('workspace-1', true)).toEqual({
stoppedSessionIds: [],
failures: [{ sessionId: SESSION, reason: 'stop-unverifiable' }]
})
expect(close).toHaveBeenCalledTimes(closes)
expect(release).toHaveBeenCalledTimes(releases)
})
it('answers a recorded ledger result without a preparation hook before requiring fresh authority', async () => {
rig = await createQueuedMessageTestRig()
const fields = { key: 'model', value: 'gpt-5' }
const envelope = rig.envelope(fields, 'agentSession.setOption', hostTestOperationId())
const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal
const request = {
...rig.host.deps,
callerKey: CALLER.callerKey,
envelope,
plan: setOptionPlan(fields),
journal: () => journal,
publish: () => {},
now: () => 1800000000000
}
expect(await admitAndRunAgentSessionMutation(request)).toMatchObject({ ok: true })
await rig.store.transitionHandoff(SESSION, (record) => ({
...record,
lease: { ...record.lease, claimStatus: 'released', ownerProcess: null }
}))
const pendingEnvelope = { ...envelope, clientOperationId: hostTestOperationId() }
expect(
await rig.store.admitMutationOperation({
callerKey: CALLER.callerKey,
envelope: pendingEnvelope,
hostFingerprint: pendingEnvelope.payloadFingerprint,
now: request.now(),
conversationWrite: true
})
).toMatchObject({ admission: { decision: 'admit' } })
damageFloor(rig.root)
const store = await openTestAgentSessionRecordStore(rig.root)
expect(await admitAndRunAgentSessionMutation({ ...request, store })).toMatchObject({
ok: true,
replayed: true,
value: fields
})
expect(
await admitAndRunAgentSessionMutation({ ...request, store, envelope: pendingEnvelope })
).toMatchObject({ ok: false, refusal: { details: { reason: 'recordUnreadable' } } })
await store.recordOperationOutcome({
callerKey: CALLER.callerKey,
operationId: pendingEnvelope.clientOperationId,
outcome: { status: 'unknown' }
})
expect(
await admitAndRunAgentSessionMutation({ ...request, store, envelope: pendingEnvelope })
).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_unknown' } })
await store.recordOperationOutcome({
callerKey: CALLER.callerKey,
operationId: pendingEnvelope.clientOperationId,
outcome: {
status: 'failed',
code: 'agent_session_conflict',
details: { reason: 'claimConflicted' }
}
})
expect(
await admitAndRunAgentSessionMutation({ ...request, store, envelope: pendingEnvelope })
).toMatchObject({ ok: false, refusal: { code: 'agent_session_conflict' } })
const operation = {
callerKey: CALLER.callerKey,
envelope,
hostFingerprint: envelope.payloadFingerprint,
now: request.now()
}
expect(await store.admitMutationOperation(operation)).toMatchObject({
admission: { decision: 'replay' },
record: { currentFence: store.getConversation(SESSION)?.currentFence }
})
expect(
await store.admitMutationOperation({
...operation,
hostFingerprint: 'changed',
envelope: { ...envelope, payloadFingerprint: 'changed' }
})
).toMatchObject({
admission: { decision: 'refused', refusal: { code: 'agent_session_operation_conflict' } }
})
await expect(
store.admitMutationOperation({
...operation,
envelope: { ...envelope, clientOperationId: hostTestOperationId() },
conversationWrite: true
})
).rejects.toMatchObject({ refusal: { details: { reason: 'recordUnreadable' } } })
expect(
await store.admitMutationOperation({
...operation,
envelope: { ...envelope, sessionId: 'missing-chat' }
})
).toBeNull()
})
function damageFloor(root: string): void {
openTestJournalHostDatabase(root)
.db.prepare(
"UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?"
)
.run(SESSION)
}
it('refuses launch and probe results when saved authority changes during preparation', async () => {
rig = await createQueuedMessageTestRig()
const identity = {
sessionId: SESSION,
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex',
providerHandle: null
}
let current = rig.store
const sources = {
store: {
selectWritableRecord: (id: string) => current.selectWritableRecord(id),
requireWritableRecord: (id: string) => current.requireWritableRecord(id),
pinLaunchDirectory: rig.store.pinLaunchDirectory
},
resolveWorkspacePath: async () => rig!.root,
resolveBaseEnvironment: async () => ({}),
resolveAgentEnvironment: async () => ({})
}
const part = async () => {
damageFloor(rig!.root)
current = await openTestAgentSessionRecordStore(rig!.root)
return { launch: { command: '/fixture/codex' }, env: {}, inheritedEnvToDelete: [] }
}
await expect(
composeStructuredLaunch(
CODEX_STRUCTURED_AGENT,
sources,
part
)({ identity, spawnToken: 'fixture-token' })
).rejects.toMatchObject({ refusal: { details: { reason: 'recordUnreadable' } } })
current = rig.store
await expect(
composeStructuredLaunchRead(CODEX_STRUCTURED_AGENT, sources, part)(identity)
).rejects.toMatchObject({ refusal: { details: { reason: 'recordUnreadable' } } })
})
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
// Keep conversation/status lifetimes aligned: stale host-owned Working rows bypass freshness expiry.
@@ -142,7 +143,10 @@ async function workingSession(): Promise<{
const feed = new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions,
getRecord: (sessionId) => records.get(sessionId) ?? null,
getConversation: (sessionId) => {
const record = records.get(sessionId)
return record ? agentSessionConversationView(record) : null
},
now: () => 1,
statusSink: () => ({
publish: (summary, subject) => server.ingestStructuredStatus(summary, subject),
@@ -170,7 +174,7 @@ function attachContext(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the attach reads only the members defined here.
return {
deps: {
store: { getRecord: () => null },
store: { getRecord: () => null, getConversation: () => null },
claimKeyId: 'key-1',
journalDatabase: openTestJournalHostDatabase(root)
},
@@ -1,4 +1,4 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume'
import type {
@@ -16,7 +16,7 @@ import {
} from './agent-session-published-work'
export function structuredAgentSessionProviderSessionMetadata(
record: AgentSessionRecord | null
record: AgentSessionConversationView | null
): AgentProviderSessionMetadata | undefined {
const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null
return head ? { key: 'session_id', id: head.handle.nativeId } : undefined
@@ -24,7 +24,7 @@ export function structuredAgentSessionProviderSessionMetadata(
export function readStructuredAgentSessionHistoryResult(input: {
journal: AgentSessionJournal
record: AgentSessionRecord | null
record: AgentSessionConversationView | null
request: AgentSessionHistoryRequest
scope?: AgentSessionHistoryScope
replaceNormalizedHistory?: boolean
@@ -38,7 +38,7 @@ export function readStructuredAgentSessionHistoryResult(input: {
}),
input.work
)
const fence = input.record?.lease.runtimeFence
const fence = input.record?.currentFence
const providerSession = structuredAgentSessionProviderSessionMetadata(input.record)
if (fence === undefined) {
return providerSession ? { ...result, providerSession } : result
@@ -9,7 +9,7 @@ import {
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
export type StructuredAgentSessionCurrentWorkHost = {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'replacedRuntime'>
store: Pick<AgentSessionRecordStore, 'getConversation' | 'replacedRuntime'>
sessions: {
get(
sessionId: string
@@ -43,7 +43,7 @@ export function heldStructuredAgentSessionCurrentWork(
): StructuredAgentSessionCurrentWork {
const ended = session?.lastEndedChild
return structuredAgentSessionCurrentWork(journal, {
record: store.getRecord(sessionId),
record: store.getConversation(sessionId),
replaced: store.replacedRuntime(sessionId),
child: session?.child ?? null,
...(ended ? { ended } : {}),
@@ -90,7 +90,7 @@ export function createStructuredAgentSessionConversationDelivery(input: {
childWork: () => input.clientDelivery.readChildWork(sessionId),
childWorkOpen: () => input.clientDelivery.childWorkOpen(sessionId),
hasOpenDispatch: () => {
const record = deps.store.getRecord(sessionId)
const record = deps.store.getConversation(sessionId)
return record !== null && deps.hasOpenDispatch?.(record) === true
},
providerHoldsDispatch: () =>
@@ -123,7 +123,10 @@ export function createStructuredAgentSessionConversationDelivery(input: {
? true
: (deps.adapter.permissionModeRequiresAcquisition?.(sessionId, mode) ??
mode !== session?.child?.permissionMode),
stopAgent: input.stopOutgrownAgent,
stopAgent: (sessionId) => {
deps.store.requireWritableRecord(sessionId)
return input.stopOutgrownAgent(sessionId)
},
logger: deps.logger
})
},
@@ -155,7 +158,7 @@ export function createStructuredAgentSessionConversationDelivery(input: {
},
failureTextContext: (sessionId) =>
structuredAgentSessionFailureWordsContext(
deps.store.getRecord(sessionId),
deps.store.getConversation(sessionId),
sessions.get(sessionId)?.journal
),
logger: deps.logger,
@@ -60,7 +60,7 @@ export type StructuredAgentSessionLifetimeContext = {
}
type ConversationCloseDeps = Pick<StructuredAgentSessionHostDeps, 'logger'> & {
store: Pick<StructuredAgentSessionHostDeps['store'], 'getRecord'>
store: Pick<StructuredAgentSessionHostDeps['store'], 'getConversation'>
}
/** Keep personal unsent text as cards and mark reopen before close; quit leaves recovery to next open.
@@ -176,12 +176,12 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
)
}
if ('quit' in ending && ending.quit) {
const record = context.deps.store.getRecord(sessionId)
const record = context.deps.store.getConversation(sessionId)
await recordStructuredAgentSessionShutdownCut({
journal: session.journal,
sessionId,
fence: child.fence,
rowFence: Math.max(child.fence, record?.lease.runtimeFence ?? child.fence),
rowFence: Math.max(child.fence, record?.currentFence ?? child.fence),
generation: child.generation ?? 'unknown',
turnItemId: quitCuts,
trigger: ending.quit,
@@ -22,7 +22,10 @@ export function createStructuredAgentSessionHostStatusFeed(args: {
now: () => number
currentWork: StructuredAgentSessionSubagentRowsDeps['currentWork']
deps: () => {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'replacedRuntime' | 'listRecords'>
store: Pick<
AgentSessionRecordStore,
'getConversation' | 'replacedRuntime' | 'listConversations'
>
logger: StructuredAgentSessionLogger
onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged']
statusSink?: StructuredAgentSessionStatusSink
@@ -58,8 +61,8 @@ export function createStructuredAgentSessionHostStatusFeed(args: {
const feed = new StructuredAgentSessionStatusFeed({
sessions: args.sessions,
starting: args.starting,
getRecord: (sessionId) => args.deps().store.getRecord(sessionId),
listRecords: () => args.deps().store.listRecords(),
getConversation: (sessionId) => args.deps().store.getConversation(sessionId),
listConversations: () => args.deps().store.listConversations(),
replacedRuntime: (sessionId) => args.deps().store.replacedRuntime(sessionId),
now: args.now,
logger: deferredStructuredAgentSessionLogger(() => args.deps().logger),
@@ -65,7 +65,7 @@ export function createStructuredAgentSessionTabSurface(
deps: {
store: Pick<
AgentSessionRecordStore,
'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getRecord'
'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getConversation'
>
}
},
@@ -78,7 +78,7 @@ export function createStructuredAgentSessionTabSurface(
listSessionTabs: (): StructuredAgentSessionTab[] => [
...listStructuredAgentSessionTabs(sessions),
...[...unopened].flatMap((sessionId) => {
const record = sessions.has(sessionId) ? null : host.deps.store.getRecord(sessionId)
const record = sessions.has(sessionId) ? null : host.deps.store.getConversation(sessionId)
return record
? [{ sessionId, workspaceId: record.location.workspaceId, agent: record.provider }]
: []
@@ -158,7 +158,9 @@ export type StructuredAgentSessionHostDeps = {
idleSweep?: { intervalMs?: number; idleMs?: number }
startupLimits?: Partial<StructuredAgentSessionStartupLimits>
/** Absent means no dispatch ownership. */
hasOpenDispatch?: (record: AgentSessionRecord) => boolean
hasOpenDispatch?: (
record: Pick<AgentSessionRecord, 'sessionId' | 'location' | 'conversationCommand'>
) => boolean
/** Advisory tab-hidden notice; failures are logged. */
onSessionTabHidden?: (sessionId: string) => void
/** Throws while a terminal may still write the record's head conversation; run before a spawn. */
@@ -168,7 +168,7 @@ export class StructuredAgentSessionHost {
loadedSessionIds = (): string[] =>
[...this.sessions].flatMap(([sessionId, { child }]) => (child ? [sessionId] : []))
hasLoadedProviders = (): boolean => this.loadedSessionIds().length > 0
sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null
sessionAgent = (sessionId: string) => this.deps.store.getConversation(sessionId)?.provider ?? null
handleAdapterEvent = (event: Parameters<StructuredAgentSessionEventRecovery['handle']>[0]) =>
this.eventRecovery.handle(event)
@@ -347,7 +347,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => {
now: () => IDLE_MS + 1,
isDisposed: () => false,
deliveryActive: () => false,
store: { getRecord: () => null, replacedRuntime: () => undefined },
store: { getConversation: () => null, replacedRuntime: () => undefined },
childWorkOpen: () => false,
hasOpenDispatch: () => false,
providerHoldsDispatch: () => false,
@@ -15,7 +15,7 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session-
/** Use only the saved, folder-scoped catalog so startup never waits on a listing. */
async function listedAutoModeSupport(
catalog: StructuredAgentSessionHostDeps['modelCatalog'],
record: AgentSessionRecord
record: Pick<AgentSessionRecord, 'sessionId' | 'provider' | 'options' | 'permissionMode'>
): Promise<boolean> {
const listing = await catalog
?.read({
@@ -36,7 +36,7 @@ async function listedAutoModeSupport(
/** null for unregistered agents or agents without permission modes. */
export async function structuredAgentSessionLaunchPermission(
deps: Pick<StructuredAgentSessionHostDeps, 'agents' | 'modelCatalog'>,
record: AgentSessionRecord
record: Pick<AgentSessionRecord, 'sessionId' | 'provider' | 'options' | 'permissionMode'>
): Promise<AgentChatPermissionChoice | null> {
const support = agentChatPermissionModes(record.provider)
if (!support || !deps.agents.definition(record.provider)) {
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import {
continuingStructuredAgentSessionInput,
finalizingStructuredAgentSessionInput
@@ -57,7 +58,7 @@ export async function settleStructuredAgentSessionLeftovers(
const { journal, sessionId } = input
const replaced = input.store.replacedRuntime?.(sessionId)
const liveFence = structuredAgentSessionLiveFence({
record,
record: agentSessionConversationView(record),
replaced,
...(input.ended ? { ended: input.ended } : {})
})
@@ -1,3 +1,4 @@
import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view'
// Validate fingerprints centrally. Prepare ledger replays without admitting writes; first runs
// prepare ownership before lease admission, while command-receipt plans use their own admission.
@@ -11,7 +12,6 @@ import type {
AgentSessionOperationDecision,
AgentSessionOperationRow
} from '../../../shared/agent-session-operation-ledger'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionMutationEnvelope,
AgentSessionMutationResult,
@@ -36,8 +36,11 @@ import {
agentSessionOperationOutcomeUnknown,
resolveAgentSessionReplayOutcome
} from './structured-agent-session-replay-outcome'
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
import { mutationTurnContext } from './structured-agent-session-mutation-turn-context'
import type { AgentSessionReplayContext } from './structured-agent-session-turns'
import {
mutationTurnContext,
mutationReplayContext
} from './structured-agent-session-mutation-turn-context'
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
import type { StructuredAgentRegistry } from './structured-agent-registry'
import { admitCommandReceiptMutation } from './structured-agent-session-command-admission'
@@ -69,7 +72,7 @@ export type AgentSessionMutationRequest<TValue> = {
/** Prepare between ledger evaluation and lease admission; return any terminal refusal. */
prepareSession?: (
ledger: Exclude<AgentSessionOperationDecision['decision'], 'refused'>,
record: AgentSessionRecord
record: AgentSessionConversationView
) => Promise<AgentSessionMutationSessionPreparation>
publish: (journal: AgentSessionJournal) => void
wakeDelivery?: (sessionId: string) => void
@@ -78,6 +81,19 @@ export type AgentSessionMutationRequest<TValue> = {
export async function admitAndRunAgentSessionMutation<TValue>(
request: AgentSessionMutationRequest<TValue>
): Promise<AgentSessionMutationResult<TValue>> {
try {
return await admitAndRunAgentSessionMutationUnchecked(request)
} catch (error) {
if (isAgentSessionRefusalError(error)) {
return refuseAgentSessionMutation(error.refusal)
}
throw error
}
}
async function admitAndRunAgentSessionMutationUnchecked<TValue>(
request: AgentSessionMutationRequest<TValue>
): Promise<AgentSessionMutationResult<TValue>> {
const { plan, envelope } = request
const hostFingerprint = computeAgentSessionPayloadFingerprint({
@@ -92,34 +108,37 @@ export async function admitAndRunAgentSessionMutation<TValue>(
if (plan.commandReceipt) {
return admitCommandReceiptMutation(request, hostFingerprint, plan.commandReceipt)
}
const ledger = request.store.evaluateMutationOperation({
callerKey: request.callerKey,
envelope,
hostFingerprint,
now: request.now(),
...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {})
})
if (!ledger) {
return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED)
}
if (ledger.decision.decision === 'refused') {
return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, ledger.decision))
}
if (ledger.decision.decision === 'replay') {
const answered = await answerRecordedOperation(
request,
request.prepareSession,
ledger.decision.row,
ledger.record,
hostFingerprint
)
if (answered !== 'rerun') {
return answered
}
}
if (request.prepareSession) {
const ledger = request.store.evaluateMutationOperation({
callerKey: request.callerKey,
envelope,
hostFingerprint,
now: request.now(),
...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {})
})
if (!ledger) {
return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED)
}
if (ledger.decision.decision === 'refused') {
return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, ledger.decision))
}
if (ledger.decision.decision === 'replay') {
const answered = await answerRecordedOperation(
request,
request.prepareSession,
ledger.decision.row,
ledger.record,
hostFingerprint
)
if (answered !== 'rerun') {
return answered
}
}
const record = request.store.getRecord(envelope.sessionId) ?? ledger.record
const prepared = await request.prepareSession('admit', record)
const record = request.store.requireWritableRecord(envelope.sessionId)
const prepared = await request.prepareSession(
'admit',
request.store.getConversation(record.sessionId)!
)
if (!prepared.ok) {
return prepared
}
@@ -157,10 +176,12 @@ export async function admitAndRunAgentSessionMutation<TValue>(
return refuseAgentSessionMutation(admission.refusal)
}
const fence = record.lease.runtimeFence
const context = mutationTurnContext(request, journal, record)
if (admission.decision === 'replay') {
const replayed = replayRecordedOperation(request, context, admission.row)
const replayed = replayRecordedOperation(
request,
mutationReplayContext(request, journal, record),
admission.row
)
if (replayed !== 'rerun') {
return replayed
}
@@ -169,7 +190,7 @@ export async function admitAndRunAgentSessionMutation<TValue>(
envelope,
hostFingerprint,
ledger: { decision: 'admit', row: admission.row },
lease: record.lease,
lease: request.store.requireWritableRecord(envelope.sessionId).lease,
...(plan.conversationWrite ? { conversationWrite: true } : {})
})
if (rerun.decision === 'refused') {
@@ -177,6 +198,9 @@ export async function admitAndRunAgentSessionMutation<TValue>(
}
}
const current = request.store.requireWritableRecord(envelope.sessionId)
const fence = current.lease.runtimeFence
const context = mutationTurnContext(request, journal, current)
const outcome = await runSettledAgentSessionMutation({
store: request.store,
// Global replay may cross callers; settle the original caller’s durable row.
@@ -193,9 +217,9 @@ export async function admitAndRunAgentSessionMutation<TValue>(
/** Replay recorded failures directly; otherwise prepare/read without admission, rerunning only absent effects. */
async function answerRecordedOperation<TValue>(
request: AgentSessionMutationRequest<TValue>,
prepareSession: NonNullable<AgentSessionMutationRequest<TValue>['prepareSession']>,
prepareSession: AgentSessionMutationRequest<TValue>['prepareSession'],
row: AgentSessionOperationRow,
record: AgentSessionRecord,
record: AgentSessionConversationView,
hostFingerprint: string
): Promise<AgentSessionMutationResult<TValue> | 'rerun'> {
const { plan, envelope } = request
@@ -209,8 +233,8 @@ async function answerRecordedOperation<TValue>(
return refuseAgentSessionMutation(replay.refusal)
}
}
const prepared = await prepareSession('replay', record)
if (!prepared.ok) {
const prepared = await prepareSession?.('replay', record)
if (prepared && !prepared.ok) {
return prepared
}
const journal = request.journal()
@@ -233,7 +257,7 @@ async function answerRecordedOperation<TValue>(
if (current.decision.decision === 'admit') {
return 'rerun'
}
const context = mutationTurnContext(request, journal, current.record)
const context = mutationReplayContext(request, journal, current.record)
return replayRecordedOperation(request, context, current.decision.row)
}
@@ -249,7 +273,7 @@ export function replayRecordedOperation<TValue>(
>
envelope: AgentSessionMutationEnvelope
},
context: AgentSessionTurnContext,
context: AgentSessionReplayContext,
row: AgentSessionOperationRow
): AgentSessionMutationResult<TValue> | 'rerun' {
const replay = resolveAgentSessionReplayOutcome({
@@ -24,13 +24,16 @@ export function agentSessionMutationAdmitsNow<TValue>(
now: request.now(),
...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {})
})
const selected = request.store.selectWritableRecord(envelope.sessionId)
return (
selected.kind === 'writable' &&
evaluated !== null &&
evaluated.decision.decision === 'admit' &&
admitAgentSessionMutation({
envelope,
hostFingerprint,
ledger: evaluated.decision,
lease: evaluated.record.lease,
lease: selected.record.lease,
...(plan.conversationWrite ? { conversationWrite: true } : {})
}).decision === 'admit'
)
@@ -33,6 +33,7 @@ import {
performPrompt,
performSend,
type AgentSessionTurnContext,
type AgentSessionReplayContext,
type TurnOutcome
} from './structured-agent-session-turns'
import {
@@ -58,11 +59,11 @@ export type MutationPlan<TValue> = {
run: (ctx: AgentSessionTurnContext) => Promise<TurnOutcome<TValue>>
/** receipt is the accepted command receipt result, when declared. */
replay: (
ctx: AgentSessionTurnContext,
ctx: AgentSessionReplayContext,
outcome: AgentSessionOperationOutcome,
receipt?: CommandReceiptResult
) => TValue | null
rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean
rerunWhenReplayMissing?: (ctx: AgentSessionReplayContext) => boolean
recoverUnknownFromDurableState?: boolean
} & (
| {
@@ -1,7 +1,11 @@
import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission'
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
import type {
AgentSessionReplayContext,
AgentSessionTurnContext
} from './structured-agent-session-turns'
export function mutationTurnContext<TValue>(
request: Omit<AgentSessionMutationRequest<TValue>, 'plan'>,
@@ -35,3 +39,19 @@ export function mutationTurnContext<TValue>(
now: () => request.now()
}
}
export function mutationReplayContext<TValue>(
request: Omit<AgentSessionMutationRequest<TValue>, 'plan'>,
journal: AgentSessionJournal,
record: AgentSessionConversationView
): AgentSessionReplayContext {
return {
sessionId: record.sessionId,
journal,
fence: record.currentFence,
agent: record.provider,
...(request.currentWork ? { currentWork: request.currentWork } : {}),
...(record.options ? { persistedOptions: record.options } : {}),
now: request.now
}
}
@@ -4,7 +4,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store
/** Prove local unsent history; adoption, prior turns, rewind or a replacement journal defeat proof. */
export function structuredAgentSessionNeverSent(
record: AgentSessionRecord | null,
record: Pick<AgentSessionRecord, 'providerHandleChain' | 'rewind'> | null,
journal: Pick<AgentSessionJournal, 'submissions' | 'visitItems' | 'wroteBeforeOpen'>
): boolean {
if (
@@ -1,3 +1,4 @@
import { foundAgentSessionRecord } from '../../runtime/agent-session-record-founding'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
@@ -16,7 +17,26 @@ describe('an operation whose agent start throws', () => {
const log = recordingStructuredAgentSessionLogger()
const cause = new Error('EACCES: permission denied, open /Users/me/.orca/leases.json')
const context = {
deps: { logger: log.logger },
deps: {
logger: log.logger,
store: {
requireWritableRecord: () =>
foundAgentSessionRecord(
{
sessionId: SESSION,
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/test/codex' },
location: {
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree',
executionHostId: 'local',
wslDistro: null
}
},
{ claimKeyId: 'test-key', now: 0 }
)
}
},
sessions: new Map(),
publishStatus: vi.fn(),
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
@@ -30,7 +50,7 @@ describe('an operation whose agent start throws', () => {
}
const refused = await startStructuredAgentSessionAgentForOperation(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any member past its registration is read.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the store returns a complete record; the start throws at `reconcileLeases` before the remaining context is read.
context as unknown as StructuredAgentSessionAttachContext,
SESSION
)
@@ -1,3 +1,5 @@
import { agentSessionRecordFixture } from '../agent-session-record-test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { structuredAgentSessionCapabilityView } from './structured-agent-session-option-models'
import { claudeFallbackModelOptions } from '../../claude/claude-structured-session-options'
import { acquired, fakeClaude } from '../../claude/claude-structured-session-test-support'
@@ -13,8 +15,10 @@ import type { StructuredAgentSessionLiveOptions } from './structured-agent-sessi
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle'
import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind'
import {
claudeProviderHandle,
codexProviderHandle
} from '../../../shared/agent-session-provider-handle-encoding'
import type { AgentSessionModelOption } from '../../../shared/agent-session-wire'
import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service'
import {
@@ -39,10 +43,10 @@ import {
const SESSION = 'session-1'
type RestingFacts = Pick<Partial<AgentSessionRecord>, 'options' | 'provider'> & {
providerHandleChain?: Pick<AgentSessionProviderHandleLink, 'origin'>[]
rewind?: Pick<AgentSessionRewindRecord, 'phase'>
}
type RestingFacts = Pick<
Partial<AgentSessionRecord>,
'options' | 'provider' | 'providerHandleChain' | 'rewind'
>
function restingJournal(items: AgentJournalItemBody[] = [], wroteBeforeOpen = false) {
return {
@@ -57,15 +61,14 @@ function restingJournal(items: AgentJournalItemBody[] = [], wroteBeforeOpen = fa
}
function restingRecord(): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields.
return {
return agentSessionRecordFixture({
sessionId: SESSION,
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/homes/a' },
providerHandleChain: [],
location: { executionHostId: 'local', wslDistro: null },
lease: { claimStatus: 'released', ownerProcess: null, runtimeFence: 1 },
options: {}
} as unknown as AgentSessionRecord
})
}
describe('options at rest', () => {
@@ -93,7 +96,11 @@ describe('options at rest', () => {
deps: {
adapter: {},
agents: NO_STRUCTURED_AGENTS,
store: { getRecord: () => record },
store: {
getRecord: () => record,
getConversation: () => (record ? agentSessionConversationView(record) : null),
requireWritableRecord: () => record
},
modelCatalog: { read }
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
@@ -142,7 +149,11 @@ describe('options at rest', () => {
})
},
agents: NO_STRUCTURED_AGENTS,
store: { getRecord: () => record },
store: {
getRecord: () => record,
getConversation: () => (record ? agentSessionConversationView(record) : null),
requireWritableRecord: () => record
},
modelCatalog: { read: async () => ({ origin: 'probe', models: [listed], fetchedAt: 1 }) }
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
@@ -166,6 +177,10 @@ describe('options at rest', () => {
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
const modelCatalog = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
getConversation: () => {
const saved = record
return saved ? agentSessionConversationView(saved) : undefined
},
getRecord: () => record,
drivesRecord: () => true,
resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }),
@@ -184,7 +199,11 @@ describe('options at rest', () => {
deps: {
adapter: { readOptions: async () => withLiveCatalogListing(reader.read()) },
agents: NO_STRUCTURED_AGENTS,
store: { getRecord: () => record },
store: {
getRecord: () => record,
getConversation: () => (record ? agentSessionConversationView(record) : null),
requireWritableRecord: () => record
},
modelCatalog
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
@@ -230,7 +249,15 @@ describe('options at rest', () => {
})
describe('a chat at rest, read by the host', () => {
const created: RestingFacts['providerHandleChain'] = [{ origin: 'created' }]
const created = [
{
origin: 'created',
linkId: 'link-1',
mintedAtFence: 1,
observedAt: 1,
handle: codexProviderHandle('provider-session-1')
}
] satisfies AgentSessionRecord['providerHandleChain']
const turn: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'completed' }
const fast = [{ value: 'priority', label: 'Fast' }]
const flex = [...fast, { value: 'flex', label: 'Flex' }]
@@ -263,7 +290,11 @@ describe('a chat at rest, read by the host', () => {
deps: {
adapter: liveOptions ? { readOptions: async () => liveOptions } : {},
agents: claudeAndCodexAgents(),
store: { getRecord: () => record },
store: {
getRecord: () => record,
getConversation: () => (record ? agentSessionConversationView(record) : null),
requireWritableRecord: () => record
},
modelCatalog: catalog(models, verified)
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
@@ -275,11 +306,31 @@ describe('a chat at rest, read by the host', () => {
it.each<[string, RestingFacts, AgentJournalItemBody[], boolean, string[]]>([
['started here names its default as its own', {}, [], false, []],
['taken from history', { providerHandleChain: [{ origin: 'adopted' }] }, [], true, ['model']],
// Context clear hides the newest turn, not its historical existence.
[
'taken from history',
{ providerHandleChain: [{ ...created[0]!, origin: 'adopted' }] },
[],
true,
['model']
],
// A context clear hides the turn from the newest-turn read, not from history.
['with a turn before a context clear', {}, [turn], true, ['model']],
['after a rewind', { rewind: { phase: 'completed' } }, [], true, ['model']],
// A replacement journal cannot prove the agent never ran.
[
'after a rewind',
{
rewind: {
phase: 'completed',
operationId: 'rewind-1',
callerKey: 'caller-1',
itemId: 'item-1',
expectedEpoch: 'epoch-1'
}
},
[],
true,
['model']
],
// A journal founded afresh proves nothing for a chat whose agent already ran.
['whose journal went missing', { providerHandleChain: created }, [], false, ['model']]
])('%s', async (_name, facts, items, wroteBeforeOpen, defaulted) => {
const read = await readAtRest(facts, items, wroteBeforeOpen)
@@ -370,7 +421,16 @@ describe('a chat at rest, read by the host', () => {
)
expect(structuredAgentSessionSendSelection(state)).toEqual(model ? { model } : undefined)
const resumed = await readAtRest(
{ provider: 'claude', providerHandleChain: [{ origin: 'adopted' }] },
{
provider: 'claude',
providerHandleChain: [
{
...created[0]!,
origin: 'adopted',
handle: claudeProviderHandle('provider-session-1', null)
}
]
},
[],
true,
installed,
@@ -479,7 +539,12 @@ describe('a chat at rest, read by the host', () => {
) {
let record: AgentSessionRecord = { ...restingRecord(), options, provider }
const store = {
getConversation: () => {
const saved = record
return saved ? agentSessionConversationView(saved) : undefined
},
getRecord: () => record,
requireWritableRecord: () => record,
replaceSessionOptions: vi.fn(),
reviseSessionOptions: async <A>(
revise: (held: AgentSessionRecord, args: A) => AgentSessionRecord,
@@ -565,7 +630,11 @@ describe('a chat at rest, read by the host', () => {
deps: {
adapter: { prepareReadOptions },
agents: NO_STRUCTURED_AGENTS,
store: { getRecord: () => record }
store: {
getRecord: () => record,
getConversation: () => (record ? agentSessionConversationView(record) : null),
requireWritableRecord: () => record
}
},
serialize: (sessionId: string, task: () => Promise<unknown>) =>
queue.serialize(sessionId, task),
@@ -608,7 +677,11 @@ describe('a chat at rest, read by the host', () => {
}
},
agents: NO_STRUCTURED_AGENTS,
store: { getRecord: () => restingRecord() }
store: {
getRecord: () => restingRecord(),
getConversation: () => agentSessionConversationView(restingRecord()),
requireWritableRecord: () => restingRecord()
}
},
serialize: (sessionId: string, task: () => Promise<unknown>) =>
queue.serialize(sessionId, task),
@@ -641,7 +714,11 @@ describe('a chat at rest, read by the host', () => {
deps: {
adapter: { prepareReadOptions },
agents: NO_STRUCTURED_AGENTS,
store: { getRecord: () => restingRecord() }
store: {
getRecord: () => restingRecord(),
getConversation: () => agentSessionConversationView(restingRecord()),
requireWritableRecord: () => restingRecord()
}
},
serialize: (sessionId: string, task: () => Promise<unknown>) =>
queue.serialize(sessionId, task),
@@ -41,7 +41,7 @@ async function readStructuredAgentSessionOptionsAtRest(
sessionId: string,
reader: StructuredAgentSessionOptionsReader
): Promise<RestingOptions> {
const record = deps.store.getRecord(sessionId)
const record = deps.store.getConversation(sessionId)
if (!record) {
throw new Error('agent_session_identity_required')
}
@@ -147,6 +147,7 @@ export async function readStructuredAgentSessionOptions(
if (!child || child.phase === 'starting') {
return { kind: 'rest' as const }
}
store.requireWritableRecord(sessionId)
const prepared = adapter.prepareReadOptions?.({ sessionId, fence: child.fence })
if (prepared) {
return { kind: 'prepared' as const, child, prepared }
@@ -167,6 +168,7 @@ export async function readStructuredAgentSessionOptions(
? await started.prepared.then((apply) =>
context.serialize(sessionId, async () => {
const child = (await context.openConversation(sessionId))?.child
store.requireWritableRecord(sessionId)
return child === started.child ? apply() : null
})
)
@@ -179,13 +181,13 @@ export async function readStructuredAgentSessionOptions(
const { catalogListing, ...answer } = live
options = answer
// Fill missing live listings from saved catalog state; absent records keep the child’s answer.
if (!catalogListing && !answer.listedModelIds && store.getRecord(sessionId)) {
if (!catalogListing && !answer.listedModelIds && store.getConversation(sessionId)) {
const { listedModelIds } = await atRest()
options = listedModelIds ? { ...answer, listedModelIds } : answer
}
if (
catalogListing &&
!independentAgentModelCatalog(store.getRecord(sessionId)?.provider ?? '')
!independentAgentModelCatalog(store.getConversation(sessionId)?.provider ?? '')
) {
// Check prior membership before saving: a launch-added row cannot verify itself.
const listedModelIds = context.deps.modelCatalog
@@ -200,7 +202,7 @@ export async function readStructuredAgentSessionOptions(
}
// Earlier reads may have closed/reopened the handle.
const session = await context.conversation(sessionId)
const record = store.getRecord(sessionId)
const record = store.getConversation(sessionId)
const phase = record?.rewind?.phase
const agent = session.params.provider
const capabilities = agents.capabilities(agent)
@@ -6,11 +6,11 @@ export function structuredAgentSessionOwnerStatus(
deps: Pick<StructuredAgentSessionHostDeps, 'store'>,
sessionId: string
): AgentSessionHandoffStatus {
const record = deps.store.getRecord(sessionId)
const record = deps.store.getConversation(sessionId)
if (!record) {
throw new Error('agent_session_identity_required')
}
const { handoffStage: stage, handoffOperationId: operationId } = record.lease
const { handoffStage: stage, handoffOperationId: operationId } = record.ownerObservation
return {
owner: 'native',
direction: stage ? 'to-native' : null,
@@ -126,6 +126,7 @@ describe('a chat permission mode', () => {
}
const catalog = createAgentModelCatalogService({
store: new AgentModelCatalogStore(),
getConversation: (id) => rig.store.getConversation(id) ?? undefined,
getRecord: (id) => rig.store.getRecord(id) ?? undefined,
drivesRecord: () => true,
recordWorkspacePath: async () => rig.root,
@@ -17,10 +17,10 @@ type ChildBearer = Pick<StructuredAgentSessionHostSession, 'child' | 'lastEndedC
/** Conversation writes use the record fence; provider writes retain the child’s acquisition fence. */
export function structuredAgentSessionConversationFence(
store: Pick<AgentSessionRecordStore, 'getRecord'>,
store: Pick<AgentSessionRecordStore, 'getConversation'>,
sessionId: string
): number {
return store.getRecord(sessionId)?.lease.runtimeFence ?? 0
return store.getConversation(sessionId)?.currentFence ?? 0
}
/** Index only successful attaches; failures have no child to remove. */
@@ -63,12 +63,12 @@ export function hostCanStartRecord(
/** Include saved chats whose agents are no longer registered when bounding client audiences. */
export function knownAgentIds(deps: {
agents: Pick<StructuredAgentRegistry, 'definitions'>
store: { listRecords: () => readonly Pick<AgentSessionRecord, 'provider'>[] }
store: { listConversations: () => readonly Pick<AgentSessionRecord, 'provider'>[] }
}): readonly string[] {
return [
...new Set([
...deps.agents.definitions().map(({ agent }) => agent),
...deps.store.listRecords().map(({ provider }) => provider)
...deps.store.listConversations().map(({ provider }) => provider)
])
]
}
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
import { activeProviderContext } from '../../../shared/agent-session-provider-context'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import {
createQueuedMessageTestRig,
@@ -277,3 +281,47 @@ it("refuses /compact reusing another caller's id for a different conversation",
expect((await rig.host.journalSnapshot(otherSessionId)).submissions).toHaveLength(0)
expect(rig.compact).toHaveBeenCalledOnce()
})
it('replays a completed compact after reopening an unreadable writer without compacting again', async () => {
const fields = { command: 'compact' as const }
const params = {
...fields,
envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId())
}
expect(await rig.host.conversationCommand(CALLER, params)).toMatchObject({ ok: true })
await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
rig.finishCompact()
const { command } = rig.compact.mock.calls[0]![0]
await eventually(async () =>
expect(
(await rig.host.journalSnapshot(SESSION)).items.find(
({ itemId }) => itemId === agentJournalItemKey(command.identity)
)?.body
).toMatchObject({ ...command.running, state: 'completed', outcome: 'success' })
)
expect(await rig.host.conversationCommand(CALLER, params)).toMatchObject({
ok: true,
replayed: true
})
expect(rig.compact).toHaveBeenCalledOnce()
rig.host.stopDelivery()
await rig.host.flushAllStreamedEvents()
openTestJournalHostDatabase(rig.root)
.db.prepare(
"UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?"
)
.run(SESSION)
const store = await openTestAgentSessionRecordStore(rig.root)
const restored = new StructuredAgentSessionHost({ ...rig.host.deps, store })
try {
expect(store.getRecord(SESSION)).toBeNull()
expect(await restored.conversationCommand(CALLER, params)).toMatchObject({
ok: true,
replayed: true
})
expect(rig.compact).toHaveBeenCalledOnce()
} finally {
restored.stopDelivery()
await restored.flushAllStreamedEvents()
}
})
@@ -52,7 +52,7 @@ export type StructuredQueueGateInput = {
}
export function structuredQueueHold(input: StructuredQueueGateInput): StructuredQueueHold | null {
if (structuredAgentSessionSendBlock(input.record)) {
if (!input.record || structuredAgentSessionSendBlock(input.record)) {
return 'blocked'
}
// Prompts outrank working because Send-now cannot override them.
@@ -6,6 +6,7 @@ import {
type AgentSessionQueuedMessagePausedReason,
type AgentSessionQueuePause
} from '../../../shared/agent-session-wire'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
queuePauseLiftOnItsWay,
@@ -31,7 +32,9 @@ export type QueuePublication = {
export type QueueSendGate = (journal: AgentSessionJournal) => StructuredQueueGateInput
export function structuredQueueSendGate(
host: StructuredAgentSessionCurrentWorkHost,
host: StructuredAgentSessionCurrentWorkHost & {
store: Pick<AgentSessionRecordStore, 'getRecord'>
},
sessionId: string
): QueueSendGate {
return (journal) => ({
@@ -3,7 +3,9 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store
/** Refused conversions replay as returned cards, avoiding duplicate Retry rows; null means never queued. */
export function queuedSendAnswer(
journal: Pick<AgentSessionJournal, 'queuedMessages' | 'submission' | 'submissions'>,
journal: Pick<AgentSessionJournal, 'submission' | 'submissions'> & {
queuedMessages: Pick<AgentSessionJournal['queuedMessages'], 'get'>
},
clientMessageId: string
): AgentSessionSendResult | null {
const draft = journal.queuedMessages.get(clientMessageId)
@@ -1,9 +1,14 @@
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionConversationOpenDeps } from './structured-agent-session-conversation-open'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
closeTestJournalHostDatabases,
@@ -17,9 +22,10 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand
const SESSION_ID = 'codex_read_restore_fixture'
const WORKSPACE_ID = 'repo-1::/tmp/workspace'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a literal record the read path only reads; its fields match the record shape.
const RECORD = {
schemaVersion: 2,
const RECORD: AgentSessionRecord = {
...agentSessionRecordFixture(
agentSessionLeaseFixture({ sessionId: SESSION_ID, runtimeFence: 1 })
),
sessionId: SESSION_ID,
location: {
executionHostId: 'local',
@@ -28,24 +34,23 @@ const RECORD = {
workspaceKind: 'git-worktree'
},
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
providerHandleChain: [
{
linkId: 'codex-1-thread-1',
linkId: 'link-1',
handle: codexProviderHandle('thread-1'),
origin: 'created',
mintedAtFence: 1,
observedAt: 1
}
],
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
createdAt: 1,
updatedAt: 2,
lease: { sessionId: SESSION_ID, runtimeKind: 'native', runtimeFence: 1 }
} as unknown as AgentSessionRecord
]
}
const store = {
getRecord: (sessionId: string) => (sessionId === SESSION_ID ? RECORD : null)
} as unknown as AgentSessionRecordStore
const store: StructuredAgentSessionConversationOpenDeps['store'] = {
getConversation: (sessionId: string) =>
sessionId === SESSION_ID ? agentSessionConversationView(RECORD) : null,
replacedRuntime: () => undefined
}
let journalRoot: string
const openDeps = () => ({
@@ -10,7 +10,7 @@ export async function restoreStructuredAgentSessionRead(
deps: StructuredAgentSessionConversationOpenDeps,
sessionId: string
): Promise<OpenedStructuredAgentSessionConversation | null> {
const record = deps.store.getRecord(sessionId)
const record = deps.store.getConversation(sessionId)
if (!record) {
return null
}
@@ -2,7 +2,11 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import {
closeTestJournalHostDatabases,
openTestJournalHostDatabase
@@ -29,14 +33,15 @@ describe('StructuredAgentSessionReadableRestorer', () => {
})
it('passes targeted records to the restore pool in visible-first order', async () => {
const records = ['background-a', 'visible-b', 'visible-a', 'background-b'].map(
(sessionId) => ({ sessionId }) as AgentSessionRecord
const records = ['background-a', 'visible-b', 'visible-a', 'background-b'].map((sessionId) =>
agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId }))
)
const restorer = new StructuredAgentSessionReadableRestorer({
openDeps: {
store: {
getRecord: () => null,
listRecords: () => records,
getConversation: () => null,
listConversations: () => records.map(agentSessionConversationView),
replacedRuntime: () => undefined
},
journalDatabase: openTestJournalHostDatabase(stateDirectory),
@@ -19,7 +19,7 @@ export class StructuredAgentSessionReadableRestorer {
? new Map(sessionIds.map((sessionId, index) => [sessionId, index]))
: null
const records = this.input.openDeps.store
.listRecords()
.listConversations()
.filter((record) => !targetOrder || targetOrder.has(record.sessionId))
if (targetOrder) {
records.sort(
@@ -0,0 +1,9 @@
import type { MutationPlan } from './structured-agent-session-mutation-plans'
export type ReadyOperationPlan<T> = Pick<
MutationPlan<T>,
'method' | 'fields' | 'operationIdScope' | 'commandReceipt'
> &
Partial<
Pick<MutationPlan<T>, 'replay' | 'rerunWhenReplayMissing' | 'recoverUnknownFromDurableState'>
>
@@ -1,3 +1,4 @@
import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
import {
agentSessionFingerprintConflict,
computeAgentSessionPayloadFingerprint
@@ -16,8 +17,8 @@ import {
replayRecordedOperation,
type AgentSessionMutationSessionPreparation
} from './structured-agent-session-mutation-admission'
import type { MutationPlan } from './structured-agent-session-mutation-plans'
import { mutationTurnContext } from './structured-agent-session-mutation-turn-context'
import type { ReadyOperationPlan } from './structured-agent-session-ready-operation-plan'
import { mutationReplayContext } from './structured-agent-session-mutation-turn-context'
import { commandReceiptScope } from '../agent-session-journal/command-receipt-schema'
import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority'
import { waitForStructuredAgentSessionStart } from './structured-agent-session-start-readiness'
@@ -39,13 +40,6 @@ export {
type ReadyOperationSelection
} from './structured-agent-session-operation-child'
type ReadyOperationPlan<T> = Pick<
MutationPlan<T>,
'method' | 'fields' | 'operationIdScope' | 'commandReceipt'
> &
Partial<
Pick<MutationPlan<T>, 'replay' | 'rerunWhenReplayMissing' | 'recoverUnknownFromDurableState'>
>
type QueuedReadyOperation<T> =
| { result: AgentSessionMutationResult<T> }
| {
@@ -76,9 +70,7 @@ export async function runWithReadyStructuredAgentSessionOperation<T>(
plan: ReadyOperationPlan<T>,
run: (selection: ReadyOperationSelection) => Promise<AgentSessionMutationResult<T>>,
options: {
beforeAgent?: (
record: AgentSessionRecord | null
) => AgentSessionMutationSessionPreparation | null
beforeAgent?: (row: AgentSessionRecord | null) => AgentSessionMutationSessionPreparation | null
goal?: boolean
needsAgent?: (record: AgentSessionRecord | null) => boolean
} = {}
@@ -171,7 +163,7 @@ export async function runWithReadyStructuredAgentSessionOperation<T>(
if (current?.decision.decision !== 'replay') {
return { result: await run({ ok: true, child: null, replay: true }) }
}
const turn = mutationTurnContext(request, journal, current.record)
const turn = mutationReplayContext(request, journal, current.record)
const answer = replayRecordedOperation(
{ envelope, plan: { ...plan, replay: plan.replay } },
turn,
@@ -181,26 +173,33 @@ export async function runWithReadyStructuredAgentSessionOperation<T>(
return { result: await run({ ok: true, child: null, replay: true }) }
}
} else if (!evaluated || evaluated.decision.decision !== 'admit') {
return {
result: await run({
ok: true,
child: null
})
}
return { result: await run({ ok: true, child: null }) }
}
}
try {
context.deps.store.requireWritableRecord(envelope.sessionId)
} catch (error) {
if (isAgentSessionRefusalError(error)) {
return { result: { ok: false as const, refusal: error.refusal } }
}
throw error
}
const select = async () => {
const opened = await openForWrite(context, envelope)()
if (!opened.ok) {
return opened
}
const checked = options.beforeAgent?.(context.deps.store.getRecord(envelope.sessionId))
const current = context.deps.store.requireWritableRecord(envelope.sessionId)
const checked = options.beforeAgent?.(current)
if (checked && !checked.ok) {
return checked
}
return selectStructuredAgentSessionOperationChild(context, envelope.sessionId)
}
const child = await select().catch((error: unknown) => {
if (isAgentSessionRefusalError(error)) {
return { ok: false as const, refusal: error.refusal }
}
context.deps.logger.warn('selecting an agent for an operation failed', {
scope: 'operation-agent-start',
sessionId: envelope.sessionId,
@@ -73,7 +73,7 @@ async function sessionWithRunningTurn() {
const feed = new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions,
getRecord: () => null,
getConversation: () => null,
now: () => clock,
statusSink: () => ({
publish: (summary, subject) => server.ingestStructuredStatus(summary, subject),
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import {
@@ -230,9 +231,13 @@ describe('a server replaced mid-turn on the same execution host', () => {
deps: Omit<StructuredAgentSessionConversationOpenDeps, 'store'>,
record: AgentSessionRecord
) => {
void openStructuredAgentSessionConversationJournal(deps, record, { acquisition: true })
void openStructuredAgentSessionConversationJournal(
deps,
agentSessionConversationView(record),
{ acquisition: true }
)
// @ts-expect-error a reader open settles, so without the store its turns would stay unverifiable
void openStructuredAgentSessionConversationJournal(deps, record)
void openStructuredAgentSessionConversationJournal(deps, agentSessionConversationView(record))
}
expect(open).toBeTypeOf('function')
})
@@ -1,3 +1,6 @@
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import { afterEach, expect, it, vi } from 'vitest'
import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
import {
@@ -47,7 +50,7 @@ it('files nothing for a chat the user moved on in before its attempt, and spends
})
it('says so in the chat when the agent cannot start for the continuation', async () => {
const { host, acquire } = await interruptedRestart()
const { host, acquire, root } = await interruptedRestart()
await host.restartResume.list()
acquire.mockRejectedValueOnce(new Error('provider could not reconnect'))
expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE])
@@ -63,6 +66,21 @@ it('says so in the chat when the agent cannot start for the continuation', async
})
// The failure note replaces the derived cut notice to avoid duplicate explanations.
expect(await readerNotes(host)).toEqual(await statusNotes(host))
await host.flushAllStreamedEvents()
openTestJournalHostDatabase(root)
.db.prepare(
"UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?"
)
.run(SESSION)
const store = await openTestAgentSessionRecordStore(root)
const restarted = new StructuredAgentSessionHost({ ...host.deps, store })
const starts = acquire.mock.calls.length
expect(await restarted.restartResume.listFailures()).toMatchObject([
{ sessionId: SESSION, outcome: 'refused', retryable: false }
])
expect(await restarted.restartResume.list()).toEqual([])
expect(acquire).toHaveBeenCalledTimes(starts)
await restarted.flushAllStreamedEvents()
})
// Successful continuation notes follow their own message; preserve the earlier cut notice.
@@ -5,7 +5,7 @@ import type {
AgentSessionResumeFailureInput,
AgentSessionResumeFailureRecord
} from '../../runtime/agent-session-recovery-capsule-entries'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import type {
AgentSessionResumeFailureOutcome,
AgentSessionResumeMarker
@@ -32,6 +32,7 @@ type FailureCapsule = Pick<
export type StructuredAgentSessionRestartFailureLedger = {
read: () => Promise<AgentSessionResumeFailureRecord[]>
/** Saved failure rows a surface can show; retry eligibility is checked separately. */
list: () => Promise<StructuredAgentSessionResumeFailure[]>
/** File failures or complete reservations, then reopen only rows still owned by this action. */
settle: (
@@ -62,7 +63,7 @@ export function continuationFailureOutcome(
export function createStructuredAgentSessionRestartFailureLedger(deps: {
capsule?: FailureCapsule
getRecord: (sessionId: string) => AgentSessionRecord | null
getRecord: (sessionId: string) => AgentSessionConversationView | null
retryable: (marker: AgentSessionResumeMarker) => boolean
/** Preserve newer-chat failures for compatible builds, hidden here. */
savedByNewerOrca: (sessionId: string) => boolean
@@ -1,8 +1,12 @@
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import {
closeTestJournalHostDatabases,
@@ -27,7 +31,16 @@ afterAll(() => {
})
const NO_OPEN_DEPS = {
store: { getRecord: () => null, listRecords: () => [], replacedRuntime: () => undefined },
store: {
getRecord: (sessionId: string) =>
agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })),
getConversation: (sessionId: string) =>
agentSessionConversationView(
agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId }))
),
listConversations: () => [],
replacedRuntime: () => undefined
},
journalDatabase: openTestJournalHostDatabase(stateDirectory),
logger: recordingStructuredAgentSessionLogger().logger
}
@@ -53,10 +66,7 @@ describe('restart journal restoration', () => {
}
}
})
const records = Array.from(
{ length: 12 },
(_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord
)
const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` }))
const restoration = restoreStructuredAgentSessionsOnRestart({
openDeps: NO_OPEN_DEPS,
@@ -94,11 +104,7 @@ describe('restart journal restoration', () => {
turnsSeen.add(turns)
return null
})
const records = Array.from(
{ length: 12 },
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here.
(_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord
)
const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` }))
await restoreStructuredAgentSessionsOnRestart({
openDeps: NO_OPEN_DEPS,
@@ -146,8 +152,7 @@ describe('restart journal restoration', () => {
})
await restoreStructuredAgentSessionsOnRestart({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here.
records: [{ sessionId: 'session-1' } as AgentSessionRecord],
records: [{ sessionId: 'session-1' }],
openDeps: NO_OPEN_DEPS,
reconcileAll: async () => true,
reconcile: async () => true,
@@ -166,11 +171,7 @@ describe('restart journal restoration', () => {
})
describe('once lease bookkeeping fails in a pass', () => {
const records = Array.from(
{ length: 8 },
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here.
(_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord
)
const records = Array.from({ length: 8 }, (_, index) => ({ sessionId: `session-${index}` }))
const restore = (
bookkeeping: Pick<
Parameters<typeof restoreStructuredAgentSessionsOnRestart>[0],
@@ -244,11 +245,7 @@ describe('restart journal restoration', () => {
})
const readable: string[] = []
const log = recordingStructuredAgentSessionLogger()
const records = Array.from(
{ length: 12 },
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here.
(_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord
)
const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` }))
await expect(
restoreStructuredAgentSessionsOnRestart({
@@ -279,8 +276,7 @@ describe('restart journal restoration', () => {
})
await restoreStructuredAgentSessionsOnRestart({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here.
records: [{ sessionId: 'session-1' } as AgentSessionRecord],
records: [{ sessionId: 'session-1' }],
openDeps: NO_OPEN_DEPS,
reconcileAll: async () => true,
reconcile: async () => true,
@@ -14,7 +14,7 @@ const JOURNAL_RESTORE_CONCURRENCY = 4
export type StructuredAgentSessionReadRestoreDeps = {
openDeps: StructuredAgentSessionConversationOpenDeps & {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'listRecords'>
store: Pick<AgentSessionRecordStore, 'getRecord' | 'listConversations'>
}
// Reader bookkeeping cannot grant a writer and must not block reads.
reconcileAll: () => Promise<boolean>
@@ -57,7 +57,9 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize(
}
export async function restoreStructuredAgentSessionsOnRestart(
input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] }
input: StructuredAgentSessionReadRestoreDeps & {
records: Pick<AgentSessionRecord, 'sessionId'>[]
}
): Promise<void> {
if (input.records.length === 0) {
return
@@ -67,6 +69,7 @@ export async function restoreStructuredAgentSessionsOnRestart(
const settleLeases = async (sessionId: string): Promise<void> => {
if (
settled &&
input.openDeps.store.getRecord(sessionId) &&
!((await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId)))
) {
settled = false
@@ -65,7 +65,7 @@ export function createStructuredAgentSessionRestartResume(
const witnesses = createStructuredAgentSessionRestartWitnesses({
sessions,
getRecord: deps.store.getRecord,
getRecord: deps.store.getConversation,
childWork: surfaces.readChildWork,
childWorkOpen: surfaces.childWorkOpen,
...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}),
@@ -91,7 +91,7 @@ export function createStructuredAgentSessionRestartResume(
})
const failures = createStructuredAgentSessionRestartFailureLedger({
...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}),
getRecord: deps.store.getRecord,
getRecord: deps.store.getConversation,
retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1,
savedByNewerOrca: newerOrca.has,
reveal: (markers) => revealMarkers(markers),
@@ -21,7 +21,12 @@ export type StructuredAgentSessionRestartWitnesses = {
export function createStructuredAgentSessionRestartWitnesses(deps: {
sessions: ReadonlyMap<string, NonNullable<WorkingAtStopInput['session']>>
getRecord: (sessionId: string) => AgentSessionRecord | null
getRecord: (
sessionId: string
) => Pick<
AgentSessionRecord,
'provider' | 'providerHandleChain' | 'providerContextBoundary'
> | null
childWork: WorkingAtStopInput['childWork']
childWorkOpen: WorkingAtStopInput['childWorkOpen']
capsule?: Pick<AgentSessionRecoveryCapsule, 'record'>
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
@@ -43,7 +44,7 @@ describe('the host answer a client acts on', () => {
await expect(
revealStructuredAgentSession(
{ store: { getRecord: () => stored } },
{ store: { getConversation: () => agentSessionConversationView(stored) } },
'session-answered',
open
)
@@ -60,7 +61,11 @@ describe('the host answer a client acts on', () => {
it('refuses a session this host holds no record for, opening nothing', async () => {
const open = vi.fn(async () => undefined)
await expect(
revealStructuredAgentSession({ store: { getRecord: () => null } }, 'session-absent', open)
revealStructuredAgentSession(
{ store: { getConversation: () => null } },
'session-absent',
open
)
).rejects.toThrow('agent_session_identity_required')
expect(open).not.toHaveBeenCalled()
})
@@ -71,7 +76,9 @@ describe('the host answer a client acts on', () => {
await expect(
revealStructuredAgentSession(
{
store: { getRecord: () => record('codex', 'workspace-1') }
store: {
getConversation: () => agentSessionConversationView(record('codex', 'workspace-1'))
}
},
'session-answered',
open
@@ -84,7 +91,9 @@ describe('the host answer a client acts on', () => {
await expect(
revealStructuredAgentSession(
{
store: { getRecord: () => record('codex', 'workspace-1') }
store: {
getConversation: () => agentSessionConversationView(record('codex', 'workspace-1'))
}
},
'session-answered',
async () => {
@@ -15,11 +15,11 @@ import type {
} from './structured-agent-session-host-types'
export async function revealStructuredAgentSession(
deps: { store: Pick<StructuredAgentSessionHostDeps['store'], 'getRecord'> },
deps: { store: Pick<StructuredAgentSessionHostDeps['store'], 'getConversation'> },
sessionId: string,
openConversation: (sessionId: string) => Promise<unknown>
): Promise<StructuredAgentSessionReveal> {
const record = deps.store.getRecord(sessionId)
const record = deps.store.getConversation(sessionId)
if (!record) {
throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' })
}
@@ -1,9 +1,16 @@
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import { expect, type Mock } from 'vitest'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
import { HOST_TEST_SESSION, HOST_TEST_THREAD } from './structured-agent-session-host-test-data'
import {
HOST_TEST_SESSION,
HOST_TEST_THREAD,
hostTestAttachParams,
hostTestMessage
} from './structured-agent-session-host-test-data'
type RewindReplayFixture = {
host: StructuredAgentSessionHost
@@ -66,3 +73,43 @@ export async function expectRetainedPrefixRewindReplay({
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
expect(rewind).toHaveBeenCalledTimes(1)
}
export async function expectLargeRetainedPrefixRewind({
host,
store,
rewind,
sink,
params
}: Pick<RewindReplayFixture, 'host' | 'store' | 'rewind' | 'params'> & {
sink: () => StructuredAgentSessionEventSink
}) {
expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true })
const events = sink()
const message = (turnId: string) => ({
provider: 'codex' as const,
threadId: HOST_TEST_THREAD,
turnId,
ordinal: 0
})
for (let turn = 0; turn < 300; turn++) {
events.appendItem(message(`kept-${turn}`), hostTestMessage('x'.repeat(12_000)), {
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
}
for (const turnId of ['drop', 'tip']) {
events.appendItem(message(turnId), hostTestMessage(turnId), {
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
}
await host.flushStreamedEvents(HOST_TEST_SESSION)
expect(
await host.rewind(caller, await params(agentJournalItemKey(message('drop'))))
).toMatchObject({ ok: true })
expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' }))
const { items } = await host.journalSnapshot(HOST_TEST_SESSION)
expect(items.map(({ itemId }) => itemId)).toEqual(
Array.from({ length: 300 }, (_, turn) => agentJournalItemKey(message(`kept-${turn}`)))
)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'completed' })
}
@@ -1,4 +1,5 @@
import {
expectLargeRetainedPrefixRewind,
expectSupersededRewindReplay,
expectRetainedPrefixRewindReplay
} from './structured-agent-session-rewind-replay.test-fixture'
@@ -207,6 +208,27 @@ async function params(itemId: string, epoch?: string) {
const LATE = { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId: 'late', ordinal: 0 }
describe('host rewind', () => {
it('answers a saved rewind after floor damage without another provider rewind', async () => {
const request = await params(await seed())
expect(await host.rewind(caller, request)).toMatchObject({ ok: true })
await host.flushAllStreamedEvents()
openTestJournalHostDatabase(directory)
.db.prepare(
"UPDATE agent_session_records SET record_json = json_set(record_json, '$.lease.minimumNextFence', 'bad') WHERE session_id = ?"
)
.run(HOST_TEST_SESSION)
const reopened = await openTestAgentSessionRecordStore(directory)
const restored = new StructuredAgentSessionHost({ ...host.deps, store: reopened })
const effects = rewind.mock.calls.length
expect(await restored.rewind(caller, request)).toMatchObject({
ok: true,
replayed: true,
fence: reopened.getConversation(HOST_TEST_SESSION)?.currentFence
})
expect(rewind).toHaveBeenCalledTimes(effects)
await restored.flushAllStreamedEvents()
})
it('replays the original ordinary rewind after another rewind replaces its record', () =>
expectSupersededRewindReplay({ host, store, rewind, recoverRewind, seed, params }))
@@ -331,36 +353,8 @@ describe('host rewind', () => {
expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } })
expect(rewind).toHaveBeenCalledTimes(1)
})
it('rewinds a chat whose kept part is far past the old 2 MB limit', async () => {
expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true })
const message = (turnId: string) => ({
provider: 'codex' as const,
threadId: HOST_TEST_THREAD,
turnId,
ordinal: 0
})
for (let turn = 0; turn < 300; turn++) {
sink.appendItem(message(`kept-${turn}`), hostTestMessage('x'.repeat(12_000)), {
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
}
for (const turnId of ['drop', 'tip']) {
sink.appendItem(message(turnId), hostTestMessage(turnId), {
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
}
await host.flushStreamedEvents(HOST_TEST_SESSION)
expect(
await host.rewind(caller, await params(agentJournalItemKey(message('drop'))))
).toMatchObject({ ok: true })
expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' }))
const { items } = await host.journalSnapshot(HOST_TEST_SESSION)
expect(items.map(({ itemId }) => itemId)).toEqual(
Array.from({ length: 300 }, (_, turn) => agentJournalItemKey(message(`kept-${turn}`)))
)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'completed' })
})
it('rewinds a chat whose kept part is far past the old 2 MB limit', () =>
expectLargeRetainedPrefixRewind({ host, store, rewind, sink: () => sink, params }))
it('replaces the epoch with the retained prefix and replays without another provider call', () =>
expectRetainedPrefixRewindReplay({ host, rewind, seed, params }))
it('keeps when each kept message was first seen, not when the rewind re-read it', async () => {
@@ -41,8 +41,8 @@ export async function rewindStructuredAgentSession(
} = context
const fields = { itemId: params.itemId, expectedEpoch: params.expectedEpoch }
const operation = { method: 'agentSession.rewind', fields }
// Forks need only conversation access; native rewinds require the provider.
const agent = store.getRecord(sessionId)?.provider
// A fork can be saved without a running agent; other rewinds require the provider itself.
const agent = store.getConversation(sessionId)?.provider
const rewindsByFork =
agent !== undefined &&
context.deps.agents.registration(agent)?.adapter.forkForRewind !== undefined
@@ -82,14 +82,14 @@ export async function rewindStructuredAgentSession(
return outcome.rewind
}
return completedStructuredRewind(
store.getRecord(sessionId)?.rewind,
store.getConversation(sessionId)?.rewind,
caller.callerKey,
clientOperationId
)
},
run: async (ctx) => {
await attachContext.runtimeState.flushEventSink(sessionId)
const record = store.getRecord(sessionId)!
const record = store.requireWritableRecord(sessionId)
const support = ctx.adapter.rewindSupport?.(sessionId, ctx.agent)
if (!support?.supported) {
return rewindRefusal(support?.reason ?? 'unsupported')
@@ -147,6 +147,7 @@ export async function rewindStructuredAgentSession(
}
await persistRewindRecord(store, sessionId, ctx.fence, prepared)
ctx.publish()
store.requireWritableRecord(sessionId)
const provider =
'beforeTurnId' in cut
? await ctx.adapter.rewind!({
@@ -155,7 +156,7 @@ export async function rewindStructuredAgentSession(
beforeTurnId: cut.beforeTurnId
})
: await stopForFork(context, sessionId)
const fence = store.getRecord(sessionId)!.lease.runtimeFence
const fence = store.requireWritableRecord(sessionId).lease.runtimeFence
if (!provider.ok) {
const reason = provider.reason
if (reason !== 'outcome-unknown') {
@@ -208,7 +209,7 @@ export async function rewindStructuredAgentSession(
return result.ok
? {
...result,
fence: store.getRecord(sessionId)!.lease.runtimeFence,
fence: store.getConversation(sessionId)?.currentFence ?? result.fence,
cursor: context.sessions.get(sessionId)!.journal.cursor()
}
: result
@@ -174,7 +174,7 @@ export function sendPreparation(
/** Name the recorded agent and any command a failed start leaves to retry. */
export function structuredAgentSessionFailureWordsContext(
record: AgentSessionRecord | null,
record: Pick<AgentSessionRecord, 'provider'> | null,
journal?: StructuredAgentSessionAwaitedCommandJournal
): AgentSessionFailureWordsContext {
const command = journal && structuredAgentSessionAwaitedCommand(journal)
@@ -1,6 +1,7 @@
// Read eligibility after ownership recovery commits so startup uses the resolver’s current lease.
import {
isAgentSessionRefusalError,
refuse,
type AgentSessionRefusalDetailsByCode,
type AgentSessionWireRefusalCode
@@ -25,7 +26,15 @@ export function structuredAgentSessionStartParams(
params: NonNullable<ReturnType<typeof structuredAgentSessionResumeParams>>
startupOptions?: Readonly<Record<string, string>>
} {
const record = context.deps.store.getRecord(sessionId)
let record
try {
record = context.deps.store.requireWritableRecord(sessionId)
} catch (error) {
if (isAgentSessionRefusalError(error)) {
return { ok: false, refusal: error.refusal }
}
throw error
}
if (!record) {
return refusedStructuredAgentSessionStart(
'agent_session_identity_required',
@@ -42,7 +42,7 @@ async function feedWith(sink: StructuredAgentSessionStatusSink) {
return new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions: new Map([[SESSION, session]]),
getRecord: () => null,
getConversation: () => null,
now: () => 1_000,
statusSink: () => sink
})
@@ -46,7 +46,7 @@ it('publishes the provider child startup phase, not which child it is', async ()
const feed = new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions,
getRecord: () => null,
getConversation: () => null,
now: () => 1
})
const dispose = feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) })
@@ -78,7 +78,7 @@ async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex')
const feed = new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, provider })]]),
getRecord: () => null,
getConversation: () => null,
now: () => (now += 1),
statusSink: () => sink,
onChildWorkChanged: (sessionId) => changed.push(sessionId)
@@ -355,7 +355,7 @@ describe('structured status feed over the real child-record store', () => {
const feed = new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]),
getRecord: () => null,
getConversation: () => null,
now: () => 1_000,
statusSink: () => sink
})
@@ -62,7 +62,7 @@ async function openFeed() {
const feed = new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]),
getRecord: () => null,
getConversation: () => null,
now: () => clock
})
const published: AgentSessionStatusSummary[] = []

Some files were not shown because too many files have changed in this diff Show More