mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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: () => ({})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
+6
-5
@@ -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 },
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+8
-3
@@ -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)
|
||||
|
||||
+10
-2
@@ -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)) {
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+15
-9
@@ -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,
|
||||
|
||||
+17
-11
@@ -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 }))
|
||||
})
|
||||
}
|
||||
|
||||
+13
-13
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+239
@@ -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' } } })
|
||||
})
|
||||
+6
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
+2
-2
@@ -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)) {
|
||||
|
||||
+2
-1
@@ -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 } : {})
|
||||
})
|
||||
|
||||
+65
-41
@@ -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({
|
||||
|
||||
+4
-1
@@ -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
|
||||
} & (
|
||||
| {
|
||||
|
||||
+21
-1
@@ -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 (
|
||||
|
||||
+22
-2
@@ -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
|
||||
)
|
||||
|
||||
+100
-23
@@ -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,
|
||||
|
||||
+1
@@ -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. */
|
||||
|
||||
+2
-2
@@ -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)
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
+48
@@ -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.
|
||||
|
||||
+4
-1
@@ -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
-1
@@ -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)
|
||||
|
||||
+19
-14
@@ -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
|
||||
}
|
||||
|
||||
+9
-4
@@ -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),
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+9
@@ -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'>
|
||||
>
|
||||
+19
-20
@@ -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,
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+7
-2
@@ -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')
|
||||
})
|
||||
|
||||
+19
-1
@@ -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.
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+21
-25
@@ -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
|
||||
|
||||
+2
-2
@@ -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),
|
||||
|
||||
+6
-1
@@ -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' })
|
||||
}
|
||||
|
||||
+48
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+10
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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
|
||||
})
|
||||
|
||||
+1
-1
@@ -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) })
|
||||
|
||||
+2
-2
@@ -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
|
||||
})
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user