mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
refactor(native-chat): structured agents declare their capabilities instead of shared code naming Claude and Codex (#25076)
* refactor(native-chat): keep the provider resume handle opaque to shared code
Shared structured-chat code parsed each provider's resume handle: Claude's
session id and branch leaf, Codex's thread id, through a 'claude' | 'codex'
union every new agent had to widen. The in-memory handle is now
{ transport, agent, nativeId, providerData? }: shared readers use nativeId,
lease and handle-chain checks compare transport and agent, and only the
Claude adapter reads its leaf (providerData).
Stored and wire forms are unchanged for Claude and Codex. One encoding
module writes their typed shapes and decodes both those and the neutral
shape a new transport uses, which an older build refuses as unreadable
rather than reading as Codex. Key and root strings, which fork seeds,
superseded creations and resume offers persist, stay byte-identical.
The journal's own handle type becomes the journal-row and attach-wire
encoding of the same handle, and the journal identity carries the
neutral handle (null before the provider proves one).
No user-visible change.
* fix(native-chat): derive journal-row provider handles from the journal identity
The journal row converter now takes the identity every caller already holds,
so a row's handle has one obvious constructor. Tests that wrote the in-memory
handle straight into journal rows now build it through that converter, and the
processless Claude fixture names a not-yet-proved handle as null.
* fix(native-chat): refuse a stored provider handle written in both forms
A typed Claude or Codex handle that also carries the neutral form's
transport, agent, native id or provider data named two identities; it
was read as Claude or Codex and the next write dropped the other one.
Such a row now stays unreadable and is set aside untouched.
* refactor(native-chat): route structured agents through registered definitions
The structured-chat shared layer named Claude and Codex in a closed router
type, in option reads, in frame classification, and in renderer label and
validation branches, so adding an agent meant editing shared types. Each
agent now has a definition (capabilities, resting option rules) owned by its
own module; the router is a registry of definition + adapter pairs built at
runtime setup, and shared code reads the definition instead of the name.
No behavior change for Claude or Codex; no wire or stored shape change.
* refactor(native-chat): name the structured agent list once in host types
The host types, conversation commands, provider-session ownership and the
runtime's create path spelled the structured agent list inline; they now use
the one shared type the record and wire already validate against.
* fix(native-chat): narrow the record before reading its agent's option rules
* refactor(native-chat): make the router's registrations the only agent definition lookup
The at-rest option reads looked definitions up in a second, separately populated map, so a
registered definition could route and declare capabilities while the static map decided which
options a chat at rest accepted. The router now answers `definition(agent)` from its
registrations, the at-rest readers take it through the host's adapter, and the built-in
definitions are listed only where the runtime composes the router.
* test(native-chat): use opaque handle in queued rejection fixture
* test(native-chat): share one Codex journal identity in the integration suite
Main grew the suite to the 800-line limit; the opaque-handle import pushed it
over. The two tests built the same identity inline.
* refactor(agent-session): name the handle's adapter state resumeCursor
Rename the neutral provider handle's providerData to resumeCursor before any
row persists the neutral form: it is an adapter-owned resume position (Claude's
transcript leaf), never identity. Claude/Codex stored and wire bytes are
unchanged; their typed shapes never carried the field.
State the stored-form contract (a handle's field set is closed; later per-link
data goes on the chain link, which every build preserves) and pin it with a
record round-trip test. Document that transport records the id space the
native id was minted in, which can differ from the agent's current transport.
* refactor(agent-session): one required agent registry; declarations admit what they claim
A StructuredAgentRegistry, built once from the {definition, adapter}
registrations, is now a required host dependency and the router routes with
it. The adapter interface loses its optional router-only capabilities?() and
definition?(); every reader (options at rest, thread goal, rewind, the
/compact handover) asks the registry. A live session still narrows rewind
through the adapter, and the host combines declared and narrowed in one
helper. The registry refuses a registration that declares compact, a thread
goal or rewind without the adapter method behind it.
/compact is admitted by the declared capability, so an agent that declares
compact:false gets the commandRefused fact instead of a thrown error.
Also: the cut-turn notice names the agent from the catalog, create-support
builds the account home through agentSessionAccountHome, and the turn
status text older clients read names the session's agent instead of
defaulting to Codex (Claude/Codex text unchanged).
* refactor(agent-session): the router applies the declared rewind itself
The router holds the registry, so its rewindSupport answers the owner's
declared rewind narrowed by the adapter, in one place no reader can bypass.
The host-side combining helper is gone; readers ask the adapter they hold.
* chore(agent-session): the registry is the one lookup; drop the test-only empty capability record
* test(native-chat): build the Stop-opened-turn test's identity and turn context the current way
The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle; the turn context also needs this branch's agent registry.
* test(ratchet): require src/main/provider-process now that it has landed
* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test
Main's #25706 and this branch both added the import at different lines; the merge kept both.
This commit is contained in:
@@ -20,6 +20,7 @@ import { createClaudeJournalTranslator } from './claude-structured-journal-trans
|
||||
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const SWEEP_MS = 5
|
||||
const RETRY_GAP_MS = 10 * 60_000
|
||||
@@ -79,6 +80,7 @@ beforeEach(async () => {
|
||||
setOption: async () => undefined
|
||||
}
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
|
||||
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page'
|
||||
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations'
|
||||
import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read'
|
||||
@@ -29,6 +28,11 @@ import {
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
import {
|
||||
claudeAndCodexAgents,
|
||||
NO_STRUCTURED_AGENTS
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
|
||||
import type { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry'
|
||||
|
||||
const SESSION = 'orca-session'
|
||||
const journals = createTrackedJournalOpener()
|
||||
@@ -81,10 +85,7 @@ function runTurn(
|
||||
})
|
||||
}
|
||||
|
||||
function readOptions(
|
||||
journal: AgentSessionJournal,
|
||||
adapter: Partial<StructuredAgentSessionAdapter>
|
||||
) {
|
||||
function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegistry) {
|
||||
const running = {
|
||||
journal,
|
||||
child: { fence: 1, generation: 'generation-1' },
|
||||
@@ -94,9 +95,9 @@ function readOptions(
|
||||
const context = {
|
||||
deps: {
|
||||
adapter: {
|
||||
readOptions: async () => ({ models: [], current: { model: 'opus' } }),
|
||||
...adapter
|
||||
readOptions: async () => ({ models: [], current: { model: 'opus' } })
|
||||
},
|
||||
agents,
|
||||
store: { getRecord: () => undefined }
|
||||
},
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
@@ -171,7 +172,7 @@ describe('context usage for a turn row outside the loaded page', () => {
|
||||
const state = attachTail(journal, 3)
|
||||
expect(state.hasOlder).toBe(true)
|
||||
expect(hasTurnRow(state)).toBe(false)
|
||||
const options = await readOptions(journal, { recordsContextUsage: () => true })
|
||||
const options = await readOptions(journal, claudeAndCodexAgents())
|
||||
|
||||
expect(options.contextUsage?.current).toEqual(
|
||||
latestStructuredAgentContextFacts(journal.snapshot().items)
|
||||
@@ -190,7 +191,7 @@ describe('context usage for a turn row outside the loaded page', () => {
|
||||
runTurn(translator, 'turn-a', 1_000, [120_000, 130_000, 140_000])
|
||||
await settle()
|
||||
|
||||
const options = await readOptions(journal, {})
|
||||
const options = await readOptions(journal, NO_STRUCTURED_AGENTS)
|
||||
expect(options).not.toHaveProperty('contextUsage')
|
||||
// An older host answers the same way, so the ring reads only the loaded page, as before.
|
||||
expect(selectStructuredAgentContextUsage(attachTail(journal, 2).items)).toBeNull()
|
||||
@@ -213,7 +214,7 @@ describe('context usage for a turn row outside the loaded page', () => {
|
||||
|
||||
expect(hasTurnRow(live)).toBe(false)
|
||||
expect(live.unloadedTurnRevisions).toBe(1)
|
||||
const options = await readOptions(journal, { recordsContextUsage: () => true })
|
||||
const options = await readOptions(journal, claudeAndCodexAgents())
|
||||
expect(
|
||||
selectStructuredAgentContextUsage(live.items, options.contextUsage?.current)
|
||||
).toMatchObject({ usedTokens: 250_000, windowTokens: 1_000_000 })
|
||||
@@ -227,7 +228,7 @@ describe('context usage for a turn row outside the loaded page', () => {
|
||||
runTurn(translator, 'turn-b', 3_000, [])
|
||||
await settle()
|
||||
const hostAnswer = async () =>
|
||||
(await readOptions(journal, { recordsContextUsage: () => true })).contextUsage?.current
|
||||
(await readOptions(journal, claudeAndCodexAgents())).contextUsage?.current
|
||||
// The client reads the host once per turn, and again whenever its window loses a turn row.
|
||||
let state = attachTail(journal, 200)
|
||||
let host = await hostAnswer()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition'
|
||||
import { isClaudeStructuredOptionKey } from './claude-structured-options'
|
||||
import { claudeFallbackModelOptions } from './claude-structured-session-options'
|
||||
|
||||
export const CLAUDE_STRUCTURED_AGENT: StructuredAgentDefinition = {
|
||||
agent: 'claude',
|
||||
capabilities: {
|
||||
// Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork.
|
||||
rewind: false,
|
||||
compact: true,
|
||||
threadGoal: false,
|
||||
// A session at rest still reports the usage its journal recorded.
|
||||
contextUsage: true,
|
||||
imagePrompts: true,
|
||||
steering: 'inject',
|
||||
// The permission mode is a launch flag the CLI enforces.
|
||||
approvalEnforcement: 'provider'
|
||||
},
|
||||
restingOptions: {
|
||||
acceptsKey: isClaudeStructuredOptionKey,
|
||||
fallbackModels: claudeFallbackModelOptions,
|
||||
effortDefaultsToModel: true
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-mode
|
||||
import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store'
|
||||
import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations'
|
||||
import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read'
|
||||
import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
|
||||
import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog'
|
||||
import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration'
|
||||
import {
|
||||
@@ -145,10 +146,19 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) {
|
||||
getRecord: () => record,
|
||||
resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME })
|
||||
})
|
||||
const resting = { child: null, params: { provider: 'claude' } }
|
||||
const resting = {
|
||||
child: null,
|
||||
params: { provider: 'claude' },
|
||||
journal: { threadGoal: () => null, contextUsage: () => null }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
|
||||
const context = {
|
||||
deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog },
|
||||
deps: {
|
||||
adapter: {},
|
||||
agents: claudeAndCodexAgents(),
|
||||
store: { getRecord: () => record },
|
||||
modelCatalog
|
||||
},
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
openConversation: async () => resting,
|
||||
conversation: async () => resting
|
||||
|
||||
@@ -34,6 +34,7 @@ import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
|
||||
import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin'
|
||||
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
||||
import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home'
|
||||
|
||||
export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const
|
||||
export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS'
|
||||
@@ -250,8 +251,9 @@ export function createClaudeStructuredLaunchResolver(
|
||||
`claude structured sessions run on the local host, not ${record.location.executionHostId}`
|
||||
)
|
||||
}
|
||||
if (record.accountHome.variable !== 'CLAUDE_CONFIG_DIR') {
|
||||
throw new Error(`claude sessions pin CLAUDE_CONFIG_DIR, not ${record.accountHome.variable}`)
|
||||
const pinned = agentConfigDirectoryVariable('claude')
|
||||
if (record.accountHome.variable !== pinned) {
|
||||
throw new Error(`claude sessions pin ${pinned}, not ${record.accountHome.variable}`)
|
||||
}
|
||||
// Every acquisition, not just the first: the account state can change under a live session, and
|
||||
// a reacquire after an unexpected exit would otherwise spawn under whatever it has become.
|
||||
|
||||
@@ -81,12 +81,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
|
||||
supportsLocation = supportsClaudeStructuredLocation
|
||||
|
||||
// Orca's marker-based rewind proof can never pass on the real binary; rewind returns via a fork.
|
||||
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = () => ({
|
||||
supported: false,
|
||||
reason: 'unsupported'
|
||||
})
|
||||
|
||||
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> => {
|
||||
this.settledExitErrors.delete(input.identity.sessionId)
|
||||
return acquireClaudeSession({
|
||||
@@ -267,9 +261,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
)
|
||||
readOptions = (input: { sessionId: string; fence: number }) =>
|
||||
readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
|
||||
// Provider-level: a session at rest still reports the usage its journal recorded.
|
||||
recordsContextUsage = (): boolean => true
|
||||
|
||||
readOptionRestoreFailures = (sessionId: string): readonly string[] => [
|
||||
...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? [])
|
||||
]
|
||||
|
||||
@@ -325,3 +325,13 @@ export function recordingJournalSink(): StructuredAgentSessionEventSink {
|
||||
export function tick(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
/** Delivers one frame from Claude on `connection`, under the provider session it runs. */
|
||||
export function claudeFrame(connection: FakeConnection, message: Record<string, unknown>): void {
|
||||
connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message })
|
||||
}
|
||||
|
||||
/** Whether anything sent to Claude on `connection` carries `text`. */
|
||||
export function claudeWasSent(connection: FakeConnection, text: string): boolean {
|
||||
return connection.sent.some((message) => JSON.stringify(message).includes(text))
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createCodexJournalTranslator } from './codex-structured-journal-transla
|
||||
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const SWEEP_MS = 5
|
||||
const RETRY_GAP_MS = 10 * 60_000
|
||||
@@ -77,6 +78,7 @@ beforeEach(async () => {
|
||||
setOption: async () => undefined
|
||||
}
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition'
|
||||
import { isCodexTurnOptionKey } from './codex-structured-turn-start'
|
||||
|
||||
export const CODEX_STRUCTURED_AGENT: StructuredAgentDefinition = {
|
||||
agent: 'codex',
|
||||
capabilities: {
|
||||
// A thread with legacy, unpaginated history narrows this to unsupported once it runs.
|
||||
rewind: true,
|
||||
compact: true,
|
||||
// A goal change at rest starts the agent first.
|
||||
threadGoal: true,
|
||||
contextUsage: false,
|
||||
imagePrompts: true,
|
||||
steering: 'inject',
|
||||
// Approval and sandbox policy ride on the thread; the app-server enforces them.
|
||||
approvalEnforcement: 'provider'
|
||||
},
|
||||
restingOptions: {
|
||||
acceptsKey: isCodexTurnOptionKey,
|
||||
// No built-in list: the client fills the current model from its own unknown-model defaults.
|
||||
fallbackModels: () => null,
|
||||
// A running child answers only the effort its thread reported, never the model's default.
|
||||
effortDefaultsToModel: false
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
|
||||
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
|
||||
import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof'
|
||||
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
|
||||
import { agentConfigDirectoryVariable } from '../../shared/agent-session-account-home'
|
||||
|
||||
export type CodexStructuredLaunchResolverDeps = {
|
||||
store: AgentSessionRecordStore
|
||||
@@ -85,8 +86,9 @@ export function createCodexStructuredLaunchResolver(
|
||||
) {
|
||||
throw new Error('codex structured sessions require Windows process creation-time proof')
|
||||
}
|
||||
if (accountHome.variable !== 'CODEX_HOME') {
|
||||
throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`)
|
||||
const pinned = agentConfigDirectoryVariable('codex')
|
||||
if (accountHome.variable !== pinned) {
|
||||
throw new Error(`codex sessions pin ${pinned}, not ${accountHome.variable}`)
|
||||
}
|
||||
const { command, environment } = await resolveCodexStructuredInvocation(deps)
|
||||
// `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -95,6 +96,7 @@ beforeEach(async () => {
|
||||
}
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter,
|
||||
|
||||
@@ -259,9 +259,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
this.deps.requestTimeoutMs
|
||||
)
|
||||
|
||||
// Provider-level: a goal change at rest starts the agent first.
|
||||
supportsThreadGoal = (): boolean => true
|
||||
|
||||
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) =>
|
||||
answerCodexStructuredPrompt({ request, sessions: this.sessions })
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
|
||||
import { CodexPromptRegistry } from './codex-structured-prompt-replies'
|
||||
import type { CodexSession } from './codex-structured-session-state'
|
||||
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
|
||||
import { claudeAndCodexRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
|
||||
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
|
||||
const THREAD = 'thread-1'
|
||||
@@ -211,7 +211,7 @@ describe('Codex structured session close lifecycle', () => {
|
||||
|
||||
it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => {
|
||||
const { adapter, connections, events } = adapterFixture()
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
const router = claudeAndCodexRouter(
|
||||
{ claude: claudeAdapterStub(), codex: adapter },
|
||||
async () => {}
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
AgentModelCatalogSessionAccess,
|
||||
AgentModelCatalogStore
|
||||
} from './agent-model-catalog-store'
|
||||
import { agentConfigDirectoryVariable } from '../../../shared/agent-session-account-home'
|
||||
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
|
||||
/**
|
||||
* Everything that changes which models a listing can answer with: the agent,
|
||||
@@ -55,7 +57,7 @@ export function agentModelCatalogFingerprintForRecord(
|
||||
* Native only: both structured adapters refuse non-native locations at launch. */
|
||||
export function agentModelCatalogSessionAccess(
|
||||
store: AgentModelCatalogStore | undefined,
|
||||
agent: 'claude' | 'codex',
|
||||
agent: AgentSessionHandleProvider,
|
||||
accountHomePath: string | null
|
||||
): AgentModelCatalogSessionAccess | undefined {
|
||||
if (!store || !accountHomePath) {
|
||||
@@ -65,7 +67,7 @@ export function agentModelCatalogSessionAccess(
|
||||
store,
|
||||
fingerprint: agentModelCatalogFingerprint({
|
||||
agent,
|
||||
accountHomeVariable: agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
|
||||
accountHomeVariable: agentConfigDirectoryVariable(agent),
|
||||
accountHomePath,
|
||||
wslDistro: null
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionAccountHome,
|
||||
AgentSessionRecord
|
||||
} from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
import {
|
||||
agentModelCatalogFingerprint,
|
||||
agentModelCatalogFingerprintForRecord
|
||||
@@ -16,9 +20,7 @@ export type AgentModelCatalogServiceDeps = {
|
||||
/** The account home a structured launch for this agent would pin right now —
|
||||
* the SAME resolver the create path fills `record.accountHome` with, so a
|
||||
* record-less read can never answer from another account's listing. */
|
||||
resolveAccountHome: (
|
||||
agent: 'claude' | 'codex'
|
||||
) => Promise<{ variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string }>
|
||||
resolveAccountHome: (agent: AgentSessionHandleProvider) => Promise<AgentSessionAccountHome>
|
||||
/** Session-less listers, one per agent that has one on this host. */
|
||||
probes?: Partial<Record<'claude' | 'codex', AgentModelCatalogProbe>>
|
||||
/** Whether the workspace's own config could pick a model other than the listed default. */
|
||||
@@ -99,7 +101,7 @@ export function createAgentModelCatalogService(
|
||||
// Probes spawn natively; a WSL-pinned record has no host-side lister.
|
||||
accountHomePath = scoped.location.wslDistro === null ? scoped.accountHome.path : null
|
||||
} else {
|
||||
let resolved: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; path: string }
|
||||
let resolved: AgentSessionAccountHome
|
||||
try {
|
||||
resolved = await deps.resolveAccountHome(params.agent)
|
||||
} catch {
|
||||
|
||||
@@ -279,24 +279,44 @@ export function isDeltaProviderFrameKind(kind: string): boolean {
|
||||
return notificationKind(kind).toLowerCase().endsWith('delta')
|
||||
}
|
||||
|
||||
function catalogClassification(
|
||||
provider: string,
|
||||
kind: string
|
||||
): ProviderFrameClassification | undefined {
|
||||
if (provider === 'codex') {
|
||||
const item = itemKind(kind)
|
||||
if (item !== null) {
|
||||
return CODEX_ITEM_CLASSIFICATIONS[item]
|
||||
const CODEX_NOTIFICATION_CLASSIFICATIONS: Readonly<Record<string, ProviderFrameClassification>> =
|
||||
PROVIDER_FRAME_CLASSIFICATIONS.codex
|
||||
const CLAUDE_FRAME_CLASSIFICATIONS: Readonly<Record<string, ProviderFrameClassification>> =
|
||||
PROVIDER_FRAME_CLASSIFICATIONS.claude
|
||||
|
||||
/** Each provider's own table, by provider id: an unlisted provider has none, so every frame it
|
||||
* sends that no rule above claims stays a visible row. */
|
||||
const PROVIDER_FRAME_CATALOGS: ReadonlyMap<
|
||||
string,
|
||||
(kind: string, payload: unknown) => ProviderFrameClassification | undefined
|
||||
> = new Map<
|
||||
keyof ProviderFrameClassificationTable,
|
||||
(kind: string, payload: unknown) => ProviderFrameClassification | undefined
|
||||
>([
|
||||
[
|
||||
'codex',
|
||||
(kind) => {
|
||||
const item = itemKind(kind)
|
||||
if (item !== null) {
|
||||
return CODEX_ITEM_CLASSIFICATIONS[item]
|
||||
}
|
||||
return CODEX_NOTIFICATION_CLASSIFICATIONS[notificationKind(kind)]
|
||||
}
|
||||
return PROVIDER_FRAME_CLASSIFICATIONS.codex[
|
||||
notificationKind(kind) as CodexAppServerNotificationMethod
|
||||
]
|
||||
}
|
||||
if (provider === 'claude') {
|
||||
return PROVIDER_FRAME_CLASSIFICATIONS.claude[kind as ClaudeStreamJsonFrameKind]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
],
|
||||
[
|
||||
'claude',
|
||||
(kind, payload) => {
|
||||
if (kind === 'message:result') {
|
||||
const subtype =
|
||||
typeof payload === 'object' && payload !== null && 'subtype' in payload
|
||||
? payload.subtype
|
||||
: undefined
|
||||
return subtype === 'success' ? 'status-chrome' : 'error-surface'
|
||||
}
|
||||
return CLAUDE_FRAME_CLASSIFICATIONS[kind]
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
export function classifyProviderFrame(
|
||||
provider: string,
|
||||
@@ -312,12 +332,5 @@ export function classifyProviderFrame(
|
||||
if (isDeltaProviderFrameKind(kind)) {
|
||||
return 'stream-into-item'
|
||||
}
|
||||
if (provider === 'claude' && kind === 'message:result') {
|
||||
const subtype =
|
||||
typeof payload === 'object' && payload !== null
|
||||
? (payload as Record<string, unknown>).subtype
|
||||
: undefined
|
||||
return subtype === 'success' ? 'status-chrome' : 'error-surface'
|
||||
}
|
||||
return catalogClassification(provider, kind) ?? 'timeline-substantive'
|
||||
return PROVIDER_FRAME_CATALOGS.get(provider)?.(kind, payload) ?? 'timeline-substantive'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// What the host knows about a structured agent before any session of it runs.
|
||||
//
|
||||
// Each agent's own module declares its definition, and runtime composition registers it with the
|
||||
// agent's adapter in one StructuredAgentRegistry. That registry is the only lookup: the router and
|
||||
// shared host code read a definition through it and never branch on the agent's name.
|
||||
|
||||
import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities'
|
||||
import type { AgentSessionModelOption } from '../../../shared/agent-session-wire'
|
||||
|
||||
export type StructuredAgentDefinition = {
|
||||
/** The Orca agent whose sessions this definition describes. */
|
||||
agent: string
|
||||
capabilities: AgentSessionCapabilities
|
||||
/** How a session's options read and change while no child runs. */
|
||||
restingOptions: {
|
||||
/** Whether the agent takes a pick of this option key. */
|
||||
acceptsKey: (key: string) => boolean
|
||||
/** The models a running child falls back to with no catalog; null when it has none. */
|
||||
fallbackModels: () => AgentSessionModelOption[] | null
|
||||
/** An unpicked effort reads as the model's default effort, as a running child reports it. */
|
||||
effortDefaultsToModel: boolean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// The structured agents this runtime drives, built once from their registrations.
|
||||
//
|
||||
// The one place a definition is looked up: the router routes with it and every host reader asks it
|
||||
// what an agent declares. A declaration the agent's adapter could not honour is refused here, so a
|
||||
// declared capability always has the adapter method behind it.
|
||||
|
||||
import type { AgentSessionCapabilities } from '../../../shared/agent-session-capabilities'
|
||||
import type { StructuredAgentDefinition } from './structured-agent-definition'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
|
||||
/** One structured agent this runtime drives: its definition, and the adapter that runs it. */
|
||||
export type StructuredAgentRegistration = {
|
||||
definition: StructuredAgentDefinition
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
}
|
||||
|
||||
type AdapterMethod = keyof StructuredAgentSessionAdapter
|
||||
|
||||
/** The adapter methods each declared capability needs. */
|
||||
function requiredAdapterMethods(capabilities: AgentSessionCapabilities): AdapterMethod[] {
|
||||
return [
|
||||
...(capabilities.compact ? (['compact'] as const) : []),
|
||||
...(capabilities.threadGoal ? (['changeThreadGoal'] as const) : []),
|
||||
...(capabilities.rewind ? (['rewind', 'recoverRewind'] as const) : [])
|
||||
]
|
||||
}
|
||||
|
||||
export class StructuredAgentRegistry {
|
||||
private readonly byAgent: ReadonlyMap<string, StructuredAgentRegistration>
|
||||
|
||||
constructor(registrations: readonly StructuredAgentRegistration[]) {
|
||||
const byAgent = new Map<string, StructuredAgentRegistration>()
|
||||
for (const registration of registrations) {
|
||||
const { agent, capabilities } = registration.definition
|
||||
if (byAgent.has(agent)) {
|
||||
throw new Error(`structured agent ${agent} is registered twice`)
|
||||
}
|
||||
const missing = requiredAdapterMethods(capabilities).find(
|
||||
(method) => !registration.adapter[method]
|
||||
)
|
||||
if (missing) {
|
||||
throw new Error(
|
||||
`structured agent ${agent} declares a capability its adapter has no ${missing} for`
|
||||
)
|
||||
}
|
||||
byAgent.set(agent, registration)
|
||||
}
|
||||
this.byAgent = byAgent
|
||||
}
|
||||
|
||||
/** The registration for `agent`; null for an agent this runtime does not drive. */
|
||||
registration(agent: string): StructuredAgentRegistration | null {
|
||||
return this.byAgent.get(agent) ?? null
|
||||
}
|
||||
|
||||
registrations(): readonly StructuredAgentRegistration[] {
|
||||
return [...this.byAgent.values()]
|
||||
}
|
||||
|
||||
definition(agent: string): StructuredAgentDefinition | null {
|
||||
return this.byAgent.get(agent)?.definition ?? null
|
||||
}
|
||||
|
||||
definitions(): readonly StructuredAgentDefinition[] {
|
||||
return this.registrations().map((registration) => registration.definition)
|
||||
}
|
||||
|
||||
/** What `agent` declares; a live session may narrow it (see `rewindSupport`), never widen it. */
|
||||
capabilities(agent: string): AgentSessionCapabilities | null {
|
||||
return this.definition(agent)?.capabilities ?? null
|
||||
}
|
||||
}
|
||||
+2
@@ -47,6 +47,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure-
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -78,6 +79,7 @@ const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spa
|
||||
|
||||
async function startHost(): Promise<void> {
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
|
||||
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition'
|
||||
import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition'
|
||||
import type {
|
||||
AgentSessionAcquisition,
|
||||
StructuredAgentSessionAdapter
|
||||
} from './structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router'
|
||||
import { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import type { StructuredAgentDefinition } from './structured-agent-definition'
|
||||
|
||||
const LOCAL: AgentSessionExecutionLocation = {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
}
|
||||
|
||||
function identity(sessionId: string, agent: string): AgentSessionJournalIdentity {
|
||||
return {
|
||||
sessionId,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent,
|
||||
providerHandle: claudeProviderHandle('provider-session-1', null)
|
||||
}
|
||||
}
|
||||
|
||||
function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition {
|
||||
return {
|
||||
process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken },
|
||||
link: {
|
||||
linkId: `link-${fence}`,
|
||||
handle: claudeProviderHandle('provider-session-1', null),
|
||||
origin: 'created',
|
||||
mintedAtFence: fence,
|
||||
observedAt: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fakeAdapter(
|
||||
overrides: Partial<StructuredAgentSessionAdapter> = {}
|
||||
): StructuredAgentSessionAdapter {
|
||||
return {
|
||||
supportsLocation: () => true,
|
||||
acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)),
|
||||
dispatch: vi.fn(),
|
||||
cancelTurn: vi.fn(),
|
||||
answerPrompt: vi.fn(),
|
||||
setOption: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** An agent this build does not ship, declared the way a new adapter would declare itself. */
|
||||
const PILOT: StructuredAgentDefinition = {
|
||||
agent: 'grok',
|
||||
capabilities: {
|
||||
rewind: false,
|
||||
compact: false,
|
||||
threadGoal: false,
|
||||
contextUsage: false,
|
||||
imagePrompts: false,
|
||||
steering: 'queue',
|
||||
approvalEnforcement: 'orca'
|
||||
},
|
||||
restingOptions: {
|
||||
acceptsKey: () => false,
|
||||
fallbackModels: () => null,
|
||||
effortDefaultsToModel: false
|
||||
}
|
||||
}
|
||||
|
||||
/** A double with every method Claude's and Codex's declarations need. */
|
||||
function declaringAdapter(
|
||||
overrides: Partial<StructuredAgentSessionAdapter> = {}
|
||||
): StructuredAgentSessionAdapter {
|
||||
return fakeAdapter({
|
||||
compact: vi.fn(),
|
||||
changeThreadGoal: vi.fn(),
|
||||
rewind: vi.fn(),
|
||||
recoverRewind: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function claudeAndCodex(
|
||||
adapters: { claude?: StructuredAgentSessionAdapter; codex?: StructuredAgentSessionAdapter } = {}
|
||||
): StructuredAgentRegistry {
|
||||
return new StructuredAgentRegistry([
|
||||
{ definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude ?? declaringAdapter() },
|
||||
{ definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex ?? declaringAdapter() }
|
||||
])
|
||||
}
|
||||
|
||||
describe('StructuredAgentSessionAdapterRouter registry', () => {
|
||||
it('routes a registered agent the router has no code for, and refuses an unregistered one', async () => {
|
||||
const pilot = fakeAdapter()
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
new StructuredAgentRegistry([{ definition: PILOT, adapter: pilot }]),
|
||||
async () => {}
|
||||
)
|
||||
|
||||
expect(router.supportsCreate(LOCAL, 'grok')).toBe(true)
|
||||
expect(router.supportsCreate(LOCAL, 'claude')).toBe(false)
|
||||
await router.acquire({ identity: identity('session-1', 'grok'), fence: 1, spawnToken: 's-1' })
|
||||
expect(pilot.acquire).toHaveBeenCalledOnce()
|
||||
await expect(
|
||||
router.acquire({ identity: identity('session-2', 'claude'), fence: 1, spawnToken: 's-2' })
|
||||
).rejects.toThrow('structured sessions do not support claude')
|
||||
})
|
||||
|
||||
it('refuses two registrations for one agent', () => {
|
||||
expect(
|
||||
() =>
|
||||
new StructuredAgentRegistry([
|
||||
{ definition: PILOT, adapter: fakeAdapter() },
|
||||
{ definition: PILOT, adapter: fakeAdapter() }
|
||||
])
|
||||
).toThrow('structured agent grok is registered twice')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['compact', 'compact'],
|
||||
['threadGoal', 'changeThreadGoal'],
|
||||
['rewind', 'rewind'],
|
||||
['rewind', 'recoverRewind']
|
||||
] as const)('refuses a declared %s whose adapter has no %s', (capability, method) => {
|
||||
const declared: StructuredAgentDefinition = {
|
||||
...PILOT,
|
||||
capabilities: { ...PILOT.capabilities, [capability]: true }
|
||||
}
|
||||
const adapter = declaringAdapter({ [method]: undefined })
|
||||
|
||||
expect(() => new StructuredAgentRegistry([{ definition: declared, adapter }])).toThrow(
|
||||
`structured agent grok declares a capability its adapter has no ${method} for`
|
||||
)
|
||||
expect(() => new StructuredAgentRegistry([{ definition: PILOT, adapter }])).not.toThrow()
|
||||
})
|
||||
|
||||
it('answers what each registered agent declares, and nothing for an unregistered one', () => {
|
||||
const agents = claudeAndCodex()
|
||||
|
||||
expect(agents.capabilities('codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities)
|
||||
expect(agents.capabilities('claude')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities)
|
||||
expect(agents.capabilities('grok')).toBeNull()
|
||||
expect(agents.definition('grok')).toBeNull()
|
||||
expect(agents.definitions()).toEqual([CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT])
|
||||
})
|
||||
|
||||
it('lets a session narrow a declared rewind but never widen an undeclared one', async () => {
|
||||
const narrowing = declaringAdapter({
|
||||
rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' })
|
||||
})
|
||||
const widening = declaringAdapter({ rewindSupport: () => ({ supported: true }) })
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
claudeAndCodex({ codex: narrowing, claude: widening }),
|
||||
async () => {}
|
||||
)
|
||||
const unsupported = { supported: false, reason: 'unsupported' }
|
||||
|
||||
expect(router.rewindSupport('session-1', 'codex')).toEqual({
|
||||
supported: false,
|
||||
reason: 'history-not-paginated'
|
||||
})
|
||||
// Claude declares no rewind; its adapter's answer cannot claim one, at rest or live.
|
||||
expect(router.rewindSupport('session-1', 'claude')).toEqual(unsupported)
|
||||
await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' })
|
||||
expect(router.rewindSupport('session-1')).toEqual(unsupported)
|
||||
expect(router.rewindSupport('session-2', 'grok')).toEqual(unsupported)
|
||||
})
|
||||
})
|
||||
|
||||
describe('structured agent definitions', () => {
|
||||
it('declares what Claude and Codex structured chats already did', () => {
|
||||
expect(CLAUDE_STRUCTURED_AGENT.capabilities).toEqual({
|
||||
rewind: false,
|
||||
compact: true,
|
||||
threadGoal: false,
|
||||
contextUsage: true,
|
||||
imagePrompts: true,
|
||||
steering: 'inject',
|
||||
approvalEnforcement: 'provider'
|
||||
})
|
||||
expect(CODEX_STRUCTURED_AGENT.capabilities).toEqual({
|
||||
rewind: true,
|
||||
compact: true,
|
||||
threadGoal: true,
|
||||
contextUsage: false,
|
||||
imagePrompts: true,
|
||||
steering: 'inject',
|
||||
approvalEnforcement: 'provider'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps each agent’s resting option rules with its definition', () => {
|
||||
const claude = CLAUDE_STRUCTURED_AGENT.restingOptions
|
||||
const codex = CODEX_STRUCTURED_AGENT.restingOptions
|
||||
expect(claude.fallbackModels()?.length).toBeGreaterThan(0)
|
||||
expect(codex.fallbackModels()).toBeNull()
|
||||
expect(claude.effortDefaultsToModel).toBe(true)
|
||||
expect(codex.effortDefaultsToModel).toBe(false)
|
||||
expect(claude.acceptsKey('model')).toBe(true)
|
||||
expect(codex.acceptsKey('model')).toBe(true)
|
||||
expect(claude.acceptsKey('no-such-option')).toBe(false)
|
||||
})
|
||||
})
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition'
|
||||
import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router'
|
||||
import type { StructuredAgentDefinition } from './structured-agent-definition'
|
||||
import { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
|
||||
/** A runtime that drives no agent: what a host over a bare adapter double declares. */
|
||||
export const NO_STRUCTURED_AGENTS = new StructuredAgentRegistry([])
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the optional methods a declaration needs; this double has none, so its agents declare none.
|
||||
const NO_METHODS = {} as StructuredAgentSessionAdapter
|
||||
|
||||
/** This build's two agents over doubles. Each declares only what its double implements, so a
|
||||
* double need not carry every method its real adapter has. */
|
||||
export function claudeAndCodexAgents(
|
||||
adapters:
|
||||
| { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter }
|
||||
| StructuredAgentSessionAdapter = NO_METHODS
|
||||
): StructuredAgentRegistry {
|
||||
const { claude, codex } = 'claude' in adapters ? adapters : { claude: adapters, codex: adapters }
|
||||
return new StructuredAgentRegistry([
|
||||
{ definition: implementedBy(CLAUDE_STRUCTURED_AGENT, claude), adapter: claude },
|
||||
{ definition: implementedBy(CODEX_STRUCTURED_AGENT, codex), adapter: codex }
|
||||
])
|
||||
}
|
||||
|
||||
/** This build's two agents exactly as declared, for a host whose double gains its methods per test. */
|
||||
export function claudeAndCodexDeclared(): StructuredAgentRegistry {
|
||||
const unused = (): never => {
|
||||
throw new Error('a registry never calls its adapters')
|
||||
}
|
||||
return claudeAndCodexAgents({
|
||||
...NO_METHODS,
|
||||
compact: unused,
|
||||
changeThreadGoal: unused,
|
||||
rewind: unused,
|
||||
recoverRewind: unused
|
||||
})
|
||||
}
|
||||
|
||||
/** A router over this build's two agents. */
|
||||
export function claudeAndCodexRouter(
|
||||
adapters: { claude: StructuredAgentSessionAdapter; codex: StructuredAgentSessionAdapter },
|
||||
closeAdapters: () => Promise<void>
|
||||
): StructuredAgentSessionAdapterRouter {
|
||||
return new StructuredAgentSessionAdapterRouter(claudeAndCodexAgents(adapters), closeAdapters)
|
||||
}
|
||||
|
||||
function implementedBy(
|
||||
definition: StructuredAgentDefinition,
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
): StructuredAgentDefinition {
|
||||
const declared = definition.capabilities
|
||||
return {
|
||||
...definition,
|
||||
capabilities: {
|
||||
...declared,
|
||||
compact: declared.compact && Boolean(adapter.compact),
|
||||
threadGoal: declared.threadGoal && Boolean(adapter.changeThreadGoal),
|
||||
rewind: declared.rewind && Boolean(adapter.rewind && adapter.recoverRewind)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -7,7 +7,7 @@ import type {
|
||||
AgentSessionAcquisition,
|
||||
StructuredAgentSessionAdapter
|
||||
} from './structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router'
|
||||
import { claudeAndCodexRouter } from './structured-agent-session-adapter-router-test-support'
|
||||
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
|
||||
function claudeIdentity(sessionId: string): AgentSessionJournalIdentity {
|
||||
@@ -51,7 +51,7 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => {
|
||||
const failure = new Error('root exited')
|
||||
const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false))
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const router = claudeAndCodexRouter({ claude, codex }, async () => {})
|
||||
const identity = claudeIdentity('session-1')
|
||||
await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' })
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => {
|
||||
claude.closeSession = closeSession
|
||||
claude.dispatch = dispatch
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const router = claudeAndCodexRouter({ claude, codex }, async () => {})
|
||||
const identity = claudeIdentity('session-1')
|
||||
await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' })
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => {
|
||||
})
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
claude.closeSession = closeSession
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
const router = claudeAndCodexRouter(
|
||||
{ claude, codex: adapterOf(vi.fn(async () => false)) },
|
||||
async () => {}
|
||||
)
|
||||
@@ -129,7 +129,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () =>
|
||||
const dispatch = vi.fn().mockResolvedValue({ state: 'unknown', reason: 'test' })
|
||||
claude.dispatch = dispatch
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const router = claudeAndCodexRouter({ claude, codex }, async () => {})
|
||||
const identity = claudeIdentity('session-1')
|
||||
await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' })
|
||||
const stopSession = router[method]
|
||||
@@ -156,7 +156,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () =>
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
claude.closeSession = closeSession
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const router = claudeAndCodexRouter({ claude, codex }, async () => {})
|
||||
await router.acquire({
|
||||
identity: claudeIdentity('session-1'),
|
||||
fence: 1,
|
||||
@@ -183,7 +183,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => {
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
claude.stopEndsSession = () => true
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const router = claudeAndCodexRouter({ claude, codex }, async () => {})
|
||||
|
||||
expect(router.stopEndsSession('session-1')).toBe(false)
|
||||
await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' })
|
||||
@@ -194,7 +194,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => {
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
claude.awaitStoppedRequestEnd = vi.fn(async () => undefined)
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const router = claudeAndCodexRouter({ claude, codex }, async () => {})
|
||||
|
||||
await router.awaitStoppedRequestEnd('session-1', 5)
|
||||
expect(claude.awaitStoppedRequestEnd).not.toHaveBeenCalled()
|
||||
@@ -207,7 +207,7 @@ describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => {
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
claude.routePromptCancel = () => ({ kind: 'stop' })
|
||||
const codex = adapterOf(vi.fn(async () => false))
|
||||
const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {})
|
||||
const router = claudeAndCodexRouter({ claude, codex }, async () => {})
|
||||
|
||||
expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toBeUndefined()
|
||||
await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' })
|
||||
@@ -222,7 +222,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => {
|
||||
const acquire = vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken))
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
claude.acquire = acquire
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
const router = claudeAndCodexRouter(
|
||||
{ claude, codex: adapterOf(vi.fn(async () => false)) },
|
||||
async () => undefined
|
||||
)
|
||||
@@ -241,7 +241,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => {
|
||||
it('keeps a per-session stop proof and reports no stop for a session it never routed', async () => {
|
||||
const claude = adapterOf(vi.fn(async () => true))
|
||||
const closeAdapters = vi.fn(async () => undefined)
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
const router = claudeAndCodexRouter(
|
||||
{ claude, codex: adapterOf(vi.fn(async () => false)) },
|
||||
closeAdapters
|
||||
)
|
||||
@@ -266,7 +266,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => {
|
||||
it('asks the adapters to release an unrouted session rather than answering from the close proof', async () => {
|
||||
const claudeRelease = vi.fn(async () => true)
|
||||
const codexRelease = vi.fn(async () => false)
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
const router = claudeAndCodexRouter(
|
||||
{ claude: adapterOf(claudeRelease), codex: adapterOf(codexRelease) },
|
||||
async () => undefined
|
||||
)
|
||||
@@ -284,7 +284,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => {
|
||||
const closeSession = vi.fn(async () => true)
|
||||
claude.dispatch = dispatch
|
||||
claude.closeSession = closeSession
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
const router = claudeAndCodexRouter(
|
||||
{ claude, codex: adapterOf(vi.fn(async () => false)) },
|
||||
vi.fn(async () => {
|
||||
throw failure
|
||||
@@ -322,7 +322,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => {
|
||||
resolveAcquire = resolve
|
||||
})
|
||||
)
|
||||
const router = new StructuredAgentSessionAdapterRouter(
|
||||
const router = claudeAndCodexRouter(
|
||||
{ claude, codex: adapterOf(vi.fn(async () => false)) },
|
||||
async () => undefined
|
||||
)
|
||||
|
||||
+52
-36
@@ -6,41 +6,49 @@ import type {
|
||||
AgentSessionExecutionLocation
|
||||
} from '../../../shared/agent-session-record'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type {
|
||||
StructuredAgentRegistration,
|
||||
StructuredAgentRegistry
|
||||
} from './structured-agent-registry'
|
||||
|
||||
type RoutedAgent = 'claude' | 'codex'
|
||||
type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' }
|
||||
type SessionRoute = {
|
||||
registration: StructuredAgentRegistration
|
||||
state: 'live' | 'stopped'
|
||||
}
|
||||
|
||||
/** Routes each session to the adapter its agent is registered with. Adding an agent is one more
|
||||
* registration; nothing here names one. */
|
||||
export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessionAdapter {
|
||||
private readonly routes = new Map<string, SessionRoute>()
|
||||
private allAdaptersClosed = false
|
||||
private closePromise: Promise<void> | null = null
|
||||
|
||||
constructor(
|
||||
private readonly adapters: Record<RoutedAgent, StructuredAgentSessionAdapter>,
|
||||
private readonly agents: StructuredAgentRegistry,
|
||||
private readonly closeAdapters: () => Promise<void>
|
||||
) {}
|
||||
|
||||
supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => {
|
||||
const adapter = this.adapterForAgent(agent)
|
||||
const adapter = this.agents.registration(agent)?.adapter
|
||||
return adapter ? (adapter.supportsLocation?.(location) ?? false) : false
|
||||
}
|
||||
|
||||
supportsLocation = (location: AgentSessionExecutionLocation): boolean =>
|
||||
Object.values(this.adapters).some((adapter) => adapter.supportsLocation?.(location) ?? false)
|
||||
this.adapters().some((adapter) => adapter.supportsLocation?.(location) ?? false)
|
||||
|
||||
/** Both adapters already gate their own shutdown, so the router only has to stop UNDOING that:
|
||||
/** Every adapter already gates its own shutdown, so the router only has to stop UNDOING that:
|
||||
* a late acquire must not clear `allAdaptersClosed` and fan a session back out to closed
|
||||
* adapters. Once closed, the router stays closed. */
|
||||
async acquire(input: Parameters<StructuredAgentSessionAdapter['acquire']>[0]) {
|
||||
if (this.allAdaptersClosed) {
|
||||
throw new Error('structured session adapter router is closed')
|
||||
}
|
||||
const adapter = this.requireAgent(input.identity)
|
||||
const acquired = await adapter.acquire(input)
|
||||
const registration = this.requireAgent(input.identity)
|
||||
const acquired = await registration.adapter.acquire(input)
|
||||
if (this.allAdaptersClosed) {
|
||||
throw new Error('structured session adapter router is closed')
|
||||
}
|
||||
this.routes.set(input.identity.sessionId, { adapter, state: 'live' })
|
||||
this.routes.set(input.identity.sessionId, { registration, state: 'live' })
|
||||
return acquired
|
||||
}
|
||||
|
||||
@@ -48,13 +56,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
const route = this.routes.get(input.sessionId)
|
||||
if (route) {
|
||||
try {
|
||||
return (await route.adapter.releaseAcquisition?.(input)) === true
|
||||
return (await route.registration.adapter.releaseAcquisition?.(input)) === true
|
||||
} finally {
|
||||
this.routes.delete(input.sessionId)
|
||||
}
|
||||
}
|
||||
let released = false
|
||||
for (const candidate of Object.values(this.adapters)) {
|
||||
for (const candidate of this.adapters()) {
|
||||
released = (await candidate.releaseAcquisition?.(input)) === true || released
|
||||
}
|
||||
return released
|
||||
@@ -63,11 +71,17 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
|
||||
this.owner(input.sessionId).dispatch(input)
|
||||
|
||||
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = (sessionId, agent) =>
|
||||
this.capabilityOwner(sessionId, agent)?.rewindSupport?.(sessionId) ?? {
|
||||
supported: false,
|
||||
reason: 'unsupported'
|
||||
/** The owner's declared rewind, narrowed by its adapter for this session; never widened. */
|
||||
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = (
|
||||
sessionId,
|
||||
agent
|
||||
) => {
|
||||
const owner = this.capabilityOwner(sessionId, agent)
|
||||
if (!owner?.definition.capabilities.rewind) {
|
||||
return { supported: false, reason: 'unsupported' }
|
||||
}
|
||||
return owner.adapter.rewindSupport?.(sessionId) ?? { supported: true }
|
||||
}
|
||||
|
||||
rewind: NonNullable<StructuredAgentSessionAdapter['rewind']> = (input) =>
|
||||
this.owner(input.sessionId).rewind?.(input) ??
|
||||
@@ -96,12 +110,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
return change(input)
|
||||
}
|
||||
|
||||
supportsThreadGoal = (sessionId: string, agent?: string): boolean =>
|
||||
this.capabilityOwner(sessionId, agent)?.supportsThreadGoal?.(sessionId) ?? false
|
||||
|
||||
recordsContextUsage = (sessionId: string, agent?: string): boolean =>
|
||||
this.capabilityOwner(sessionId, agent)?.recordsContextUsage?.(sessionId) ?? false
|
||||
|
||||
stopBackgroundTasks: NonNullable<StructuredAgentSessionAdapter['stopBackgroundTasks']> = (
|
||||
input
|
||||
) => {
|
||||
@@ -132,9 +140,10 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId)
|
||||
|
||||
atRestCommands: StructuredAgentSessionAtRestCommands = {
|
||||
read: (record) => this.adapters[record.provider].atRestCommands?.read(record),
|
||||
read: (record) =>
|
||||
this.agents.registration(record.provider)?.adapter.atRestCommands?.read(record),
|
||||
onChange: (listener) => {
|
||||
const stops = Object.values(this.adapters).flatMap((adapter) =>
|
||||
const stops = this.adapters().flatMap((adapter) =>
|
||||
adapter.atRestCommands ? [adapter.atRestCommands.onChange(listener)] : []
|
||||
)
|
||||
return () => stops.forEach((stop) => stop())
|
||||
@@ -166,7 +175,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
providerHistoryWindow = (input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
accountHome: AgentSessionAccountHome
|
||||
}) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null)
|
||||
}) =>
|
||||
this.requireAgent(input.identity).adapter.providerHistoryWindow?.(input) ??
|
||||
Promise.resolve(null)
|
||||
|
||||
closeSession = (sessionId: string): Promise<boolean> =>
|
||||
this.stopSession(sessionId, (adapter) => adapter.closeSession)
|
||||
@@ -193,8 +204,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
if (route.state === 'stopped') {
|
||||
return true
|
||||
}
|
||||
const stop = selectStop(route.adapter)
|
||||
const stopped = await stop?.call(route.adapter, sessionId)
|
||||
const { adapter } = route.registration
|
||||
const stop = selectStop(adapter)
|
||||
const stopped = await stop?.call(adapter, sessionId)
|
||||
if (stopped === true) {
|
||||
route.state = 'stopped'
|
||||
return true
|
||||
@@ -240,25 +252,29 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
return adapter
|
||||
}
|
||||
|
||||
/** The live owner, or for a session at rest the provider it would start under. */
|
||||
private capabilityOwner(sessionId: string, agent?: string): StructuredAgentSessionAdapter | null {
|
||||
return this.liveOwnerOrNull(sessionId) ?? (agent ? this.adapterForAgent(agent) : null)
|
||||
/** The live owner, or for a session at rest the agent it would start under. */
|
||||
private capabilityOwner(sessionId: string, agent?: string): StructuredAgentRegistration | null {
|
||||
const route = this.routes.get(sessionId)
|
||||
if (route?.state === 'live') {
|
||||
return route.registration
|
||||
}
|
||||
return agent ? this.agents.registration(agent) : null
|
||||
}
|
||||
|
||||
private liveOwnerOrNull(sessionId: string): StructuredAgentSessionAdapter | null {
|
||||
const route = this.routes.get(sessionId)
|
||||
return route?.state === 'live' ? route.adapter : null
|
||||
return route?.state === 'live' ? route.registration.adapter : null
|
||||
}
|
||||
|
||||
private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentSessionAdapter {
|
||||
const adapter = this.adapterForAgent(identity.agent)
|
||||
if (!adapter) {
|
||||
private requireAgent(identity: AgentSessionJournalIdentity): StructuredAgentRegistration {
|
||||
const registration = this.agents.registration(identity.agent)
|
||||
if (!registration) {
|
||||
throw new Error(`structured sessions do not support ${identity.agent}`)
|
||||
}
|
||||
return adapter
|
||||
return registration
|
||||
}
|
||||
|
||||
private adapterForAgent(agent: string): StructuredAgentSessionAdapter | null {
|
||||
return agent === 'claude' || agent === 'codex' ? this.adapters[agent] : null
|
||||
private adapters(): StructuredAgentSessionAdapter[] {
|
||||
return this.agents.registrations().map((registration) => registration.adapter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
|
||||
/** Revalidate after preparation, immediately before writing to the provider. */
|
||||
beforeDispatch?: () => Promise<void>
|
||||
}): Promise<AgentSessionDispatchOutcome>
|
||||
/** `agent` answers for a session with no child running, from the provider alone. */
|
||||
/** How this session narrows its agent's declared rewind; `agent` answers for one with no child
|
||||
* running. The router applies the declaration first, so an adapter's answer never widens it. */
|
||||
rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport
|
||||
recoverRewind?(input: {
|
||||
sessionId: string
|
||||
@@ -324,10 +325,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
|
||||
* start a new goal rather than rewrite that one's objective in place. */
|
||||
replacesGoal: boolean
|
||||
}): Promise<{ ok: true } | { ok: false; rejected: string }>
|
||||
/** Whether this session can change its goal; `agent` answers one at rest. */
|
||||
supportsThreadGoal?(sessionId: string, agent?: string): boolean
|
||||
/** Whether this session writes context facts to its turn rows; `agent` answers one at rest. */
|
||||
recordsContextUsage?(sessionId: string, agent?: string): boolean
|
||||
/** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */
|
||||
stopBackgroundTasks?(input: {
|
||||
sessionId: string
|
||||
|
||||
+2
@@ -21,6 +21,7 @@ import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const SESSION = 'codex_adopting_session'
|
||||
@@ -236,6 +237,7 @@ describe('adopting a provider conversation on create', () => {
|
||||
await writeCodexRollout(transcriptPath, 'valid source')
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI'
|
||||
@@ -130,6 +131,7 @@ beforeEach(async () => {
|
||||
}))
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -29,6 +29,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const caller = { callerKey: 'desktop' }
|
||||
const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a'
|
||||
@@ -85,6 +86,7 @@ function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog {
|
||||
async function openHost(catalog = catalogFor(workspace)): Promise<void> {
|
||||
store = await openTestAgentSessionRecordStore(directory)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
store,
|
||||
adapter: adapter(catalog),
|
||||
journalDatabase: openTestJournalHostDatabase(directory),
|
||||
|
||||
+2
@@ -29,6 +29,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -71,6 +72,7 @@ beforeEach(async () => {
|
||||
})
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexDeclared(),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
// The production router is what declares create support; the bare adapter only knows locations.
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -67,6 +68,7 @@ beforeEach(async () => {
|
||||
})
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: Object.assign(adapter, { supportsCreate: () => true }),
|
||||
|
||||
+2
@@ -18,6 +18,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-claude' }
|
||||
const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61'
|
||||
@@ -85,6 +86,7 @@ beforeEach(async () => {
|
||||
optionWritable = Promise.resolve()
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
+2
@@ -30,6 +30,7 @@ import {
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
// As Claude Code 2.1.280 advertises them on a turn's system/init frame.
|
||||
@@ -83,6 +84,7 @@ beforeEach(async () => {
|
||||
})
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: Object.assign(adapter, { supportsCreate: () => true }),
|
||||
|
||||
+2
@@ -19,6 +19,7 @@ import { endExitedStructuredAgentSessionChildUnderSerialize } from './structured
|
||||
import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state'
|
||||
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const NOW = 1_788_727_031_330
|
||||
const roots: string[] = []
|
||||
@@ -121,6 +122,7 @@ describe('Claude root-exit stop', () => {
|
||||
const deps = {
|
||||
store,
|
||||
adapter,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
// The one database the store and the journal share, as the runtime installs them.
|
||||
journalDatabase: openTestJournalHostDatabase(stateDirectory),
|
||||
claimKeyId: 'key-1',
|
||||
|
||||
+4
-8
@@ -22,6 +22,8 @@ import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait'
|
||||
import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter'
|
||||
import type { ClaudeStructuredSessionEvent } from '../../claude/claude-structured-session-state'
|
||||
import {
|
||||
claudeFrame as frame,
|
||||
claudeWasSent as wrote,
|
||||
fakeClaude,
|
||||
PROVIDER_SESSION_ID,
|
||||
type FakeConnection
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
// As Claude Code 2.1.280 advertises them on a turn's system/init frame.
|
||||
@@ -104,6 +107,7 @@ beforeEach(async () => {
|
||||
})
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
store,
|
||||
adapter: Object.assign(adapter, { supportsCreate: () => true }),
|
||||
journalDatabase: openTestJournalHostDatabase(root),
|
||||
@@ -181,10 +185,6 @@ async function dispatch(clientMessageId: string) {
|
||||
return { state: submission?.dispatchState, reason: submission?.reason }
|
||||
}
|
||||
|
||||
function frame(connection: FakeConnection, message: Record<string, unknown>): void {
|
||||
connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message })
|
||||
}
|
||||
|
||||
/** Sends a message and lets Claude open its turn and write one reply; returns the turn's id. */
|
||||
async function openTurn(connection: FakeConnection, text = 'Write a long reply.'): Promise<string> {
|
||||
const clientMessageId = await send(text)
|
||||
@@ -246,10 +246,6 @@ function stopEventsAtClose(connection: FakeConnection): () => number | undefined
|
||||
return () => atClose
|
||||
}
|
||||
|
||||
function wrote(connection: FakeConnection, text: string): boolean {
|
||||
return connection.sent.some((message) => JSON.stringify(message).includes(text))
|
||||
}
|
||||
|
||||
const INTERRUPTED_RESULT = {
|
||||
type: 'result',
|
||||
subtype: 'error_during_execution',
|
||||
|
||||
+2
@@ -20,6 +20,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec
|
||||
import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support'
|
||||
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
import {
|
||||
HOST_TEST_NOW as NOW,
|
||||
@@ -83,6 +84,7 @@ beforeEach(async () => {
|
||||
})
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexAgents(adapter),
|
||||
store,
|
||||
adapter: Object.assign(adapter, { supportsCreate: () => true }),
|
||||
journalDatabase: openTestJournalHostDatabase(root),
|
||||
|
||||
+2
@@ -30,6 +30,7 @@ import {
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
// As Claude Code 2.1.280 advertises them on a turn's system/init frame.
|
||||
@@ -70,6 +71,7 @@ beforeEach(async () => {
|
||||
})
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
store,
|
||||
adapter: Object.assign(adapter, { supportsCreate: () => true }),
|
||||
journalDatabase: openTestJournalHostDatabase(root),
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
HOST_TEST_THREAD as THREAD
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 }
|
||||
|
||||
@@ -49,6 +50,7 @@ beforeEach(() => {
|
||||
exitObservedFirst = false
|
||||
closeCalls = 0
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store: state.store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ import {
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -71,6 +72,7 @@ beforeEach(async () => {
|
||||
})
|
||||
disposeSession = vi.spyOn(adapter, 'disposeSession')
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: (log = recordingStructuredAgentSessionLogger()).logger,
|
||||
store,
|
||||
adapter: Object.assign(adapter, { supportsCreate: () => true }),
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
StructuredAgentSessionAdapter,
|
||||
StructuredAgentSessionProviderChildPhase
|
||||
} from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text'
|
||||
import { conversationCommandBlocked } from './structured-conversation-command-admission'
|
||||
|
||||
@@ -153,6 +154,7 @@ export type StructuredAgentSessionCommandHandoverContext = {
|
||||
journal: AgentSessionJournal
|
||||
fence: number
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
agents: StructuredAgentRegistry
|
||||
providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined
|
||||
/** Who a failure the handover meets names, as the start's own row does. */
|
||||
failureTextContext?: AgentSessionFailureWordsContext
|
||||
@@ -310,13 +312,17 @@ function commandBlocked(
|
||||
ctx: StructuredAgentSessionCommandHandoverContext,
|
||||
body: AgentJournalMessageItem
|
||||
): SubmissionRejectionFact | null {
|
||||
if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND || !ctx.adapter.compact) {
|
||||
if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND) {
|
||||
return agentSessionFailureFact('commandRefused')
|
||||
}
|
||||
const record = ctx.record()
|
||||
if (!record) {
|
||||
return agentSessionFailureFact('hostFault')
|
||||
}
|
||||
// The declaration admits it, as it does the advertised command list; a client may send it anyway.
|
||||
if (!ctx.agents.capabilities(record.provider)?.compact) {
|
||||
return agentSessionFailureFact('commandRefused')
|
||||
}
|
||||
const refusal = conversationCommandBlocked(ctx, record, ctx.childWork(), 'handover')
|
||||
return refusal
|
||||
? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) })
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -63,6 +64,7 @@ beforeEach(async () => {
|
||||
log = recordingStructuredAgentSessionLogger()
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: log.logger,
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -26,6 +26,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const CHILD_PID = 4321
|
||||
@@ -93,6 +94,7 @@ function host(
|
||||
overrides: Partial<StructuredAgentSessionHostDeps> = {}
|
||||
): StructuredAgentSessionHost {
|
||||
return new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter,
|
||||
|
||||
+2
@@ -49,6 +49,7 @@ import {
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const PROVIDER_SESSION = 'provider-session-alpha-1'
|
||||
/** The tool call's row: the last thing the provider wrote before the crash. */
|
||||
@@ -159,6 +160,7 @@ async function seedClaudeToolTurn(): Promise<void> {
|
||||
|
||||
function openHost(overrides: Partial<StructuredAgentSessionHostDeps>): void {
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type AgentSessionFailureWordsContext
|
||||
} from '../../../shared/agent-session-failure-words'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import {
|
||||
structuredAgentSessionStartFailure,
|
||||
type StructuredAgentSessionStartFailureCause
|
||||
@@ -45,6 +46,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo
|
||||
export type StructuredAgentSessionDeliveryLoopDeps = {
|
||||
sessions: ReadonlyMap<string, StructuredAgentSessionHostSession>
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
agents: StructuredAgentRegistry
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
/** A start step, tracked from enqueue so quit waits for the child it may produce. */
|
||||
trackStart: <T>(start: Promise<T>) => Promise<T>
|
||||
@@ -238,6 +240,7 @@ export class StructuredAgentSessionDeliveryLoop {
|
||||
journal: session.journal,
|
||||
fence: awaitedChild.fence,
|
||||
adapter: this.deps.adapter,
|
||||
agents: this.deps.agents,
|
||||
providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase,
|
||||
failureTextContext: this.deps.failureTextContext(sessionId),
|
||||
record: () => this.deps.record(sessionId),
|
||||
|
||||
+2
@@ -25,6 +25,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail'
|
||||
@@ -53,6 +54,7 @@ beforeEach(async () => {
|
||||
}))
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -23,6 +23,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in'
|
||||
@@ -49,6 +50,7 @@ beforeEach(async () => {
|
||||
}))
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -26,6 +26,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -135,6 +136,7 @@ beforeEach(async () => {
|
||||
answerPrompt = vi.fn(async ({ commit }) => commit())
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
@@ -59,6 +59,7 @@ export function createStructuredAgentSessionConversationDelivery(input: {
|
||||
const loop = new StructuredAgentSessionDeliveryLoop({
|
||||
sessions,
|
||||
adapter: deps.adapter,
|
||||
agents: deps.agents,
|
||||
serialize: input.serialize,
|
||||
trackStart: input.trackStart,
|
||||
ensureProviderChild: input.ensureProviderChild,
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function setStructuredAgentSessionOption(
|
||||
},
|
||||
run: (ctx) =>
|
||||
atRest()
|
||||
? recordStructuredAgentSessionOptionIntent(context.deps.store, ctx, params)
|
||||
? recordStructuredAgentSessionOptionIntent(context.deps, ctx, params)
|
||||
: plan.run(ctx)
|
||||
},
|
||||
openForProviderWrite(context, params.envelope)
|
||||
|
||||
+2
@@ -21,6 +21,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -68,6 +69,7 @@ describe('abandoning a structured agent-session host', () => {
|
||||
setOption: async () => undefined
|
||||
}
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
@@ -157,6 +158,7 @@ beforeEach(async () => {
|
||||
recoveryCapsule = new TrackedTestRecoveryCapsule(root)
|
||||
log = recordingProductionStructuredAgentSessionLogger()
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexDeclared(),
|
||||
logger: log.logger,
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
@@ -22,6 +22,8 @@ import type { AgentSessionAttachParams } from './structured-agent-session-attach
|
||||
import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed'
|
||||
import type { AgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
|
||||
export type StructuredAgentSessionCaller = { callerKey: string }
|
||||
|
||||
@@ -32,7 +34,7 @@ export type StructuredAgentSessionCaller = { callerKey: string }
|
||||
export type StructuredAgentSessionReveal = {
|
||||
sessionId: string
|
||||
workspaceId: string
|
||||
agent: 'claude' | 'codex'
|
||||
agent: AgentSessionHandleProvider
|
||||
readable: boolean
|
||||
/** Why the journal did not open, as a read would be refused. Host-side only: never published. */
|
||||
openRefusal?: AgentSessionWireRefusal
|
||||
@@ -109,6 +111,8 @@ export type StructuredAgentSessionHostSession = {
|
||||
export type StructuredAgentSessionHostDeps = {
|
||||
store: AgentSessionRecordStore
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
/** The agents this runtime drives; what each declares is read here, never from the adapter. */
|
||||
agents: StructuredAgentRegistry
|
||||
/** Optional advisory recovery storage, independent of conversation backups. */
|
||||
recoveryCapsule?: AgentSessionRecoveryCapsule
|
||||
/** The host's one chat journal database. */
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
let root: string
|
||||
let store: AgentSessionRecordStore
|
||||
@@ -134,6 +135,7 @@ describe('attach', () => {
|
||||
}
|
||||
}))
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: { ...adapter(), acquire },
|
||||
@@ -544,6 +546,7 @@ describe('restart', () => {
|
||||
) {
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: { ...adapter(), ...adapterOverrides },
|
||||
|
||||
+2
@@ -26,6 +26,7 @@ import {
|
||||
hostTestMessage
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } }))
|
||||
|
||||
@@ -94,6 +95,7 @@ async function relaunchWithMismatchedCopy(): Promise<StructuredAgentSessionHost>
|
||||
})
|
||||
const store = await openTestAgentSessionRecordStore(relaunched)
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
+2
@@ -31,6 +31,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure-
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -97,6 +98,7 @@ beforeEach(async () => {
|
||||
closeSession = vi.fn(async () => true)
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -26,6 +26,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -44,6 +45,7 @@ let dispatch: Mock<StructuredAgentSessionAdapter['dispatch']>
|
||||
|
||||
function openHost(): void {
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -39,6 +39,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' }
|
||||
@@ -64,6 +65,7 @@ beforeEach(async () => {
|
||||
dispatch = vi.fn(async () => ({ state: 'admitted' as const }))
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+5
-31
@@ -49,7 +49,9 @@ import {
|
||||
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 { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
|
||||
// The code is shared with the client so a read that refuses this way can be told apart from a
|
||||
// transcript that failed to load; the two must never drift apart.
|
||||
@@ -73,6 +75,7 @@ export type AgentSessionMutationSessionPreparation =
|
||||
export type AgentSessionMutationRequest<TValue> = {
|
||||
store: AgentSessionRecordStore
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
agents: StructuredAgentRegistry
|
||||
logger: StructuredAgentSessionLogger
|
||||
callerKey: string
|
||||
envelope: AgentSessionMutationEnvelope
|
||||
@@ -175,7 +178,7 @@ export async function admitAndRunAgentSessionMutation<TValue>(
|
||||
}
|
||||
|
||||
const fence = record.lease.runtimeFence
|
||||
const context = turnContext(request, journal, fence)
|
||||
const context = mutationTurnContext(request, journal, record)
|
||||
if (admission.decision === 'replay') {
|
||||
const replayed = replayRecordedOperation(request, context, admission.row)
|
||||
if (replayed !== 'rerun') {
|
||||
@@ -262,7 +265,7 @@ async function answerRecordedOperation<TValue>(
|
||||
// The row is gone since: the first-run path decides it from scratch.
|
||||
return 'rerun'
|
||||
}
|
||||
const context = turnContext(request, journal, current.record.lease.runtimeFence)
|
||||
const context = mutationTurnContext(request, journal, current.record)
|
||||
return replayRecordedOperation(request, context, current.decision.row)
|
||||
}
|
||||
|
||||
@@ -317,32 +320,3 @@ function admitWithoutLedgerRow(
|
||||
})
|
||||
return { admission, record: evaluated.record }
|
||||
}
|
||||
|
||||
function turnContext<TValue>(
|
||||
request: AgentSessionMutationRequest<TValue>,
|
||||
journal: AgentSessionJournal,
|
||||
fence: number
|
||||
): AgentSessionTurnContext {
|
||||
const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options
|
||||
return {
|
||||
sessionId: request.envelope.sessionId,
|
||||
journal,
|
||||
fence,
|
||||
adapter: request.adapter,
|
||||
logger: request.logger,
|
||||
...(persistedOptions ? { persistedOptions } : {}),
|
||||
persistOptions: (options) =>
|
||||
request.store
|
||||
.replaceSessionOptions({
|
||||
sessionId: request.envelope.sessionId,
|
||||
fence,
|
||||
options,
|
||||
now: request.now()
|
||||
})
|
||||
.then(() => undefined),
|
||||
resolvedBy: request.callerKey,
|
||||
publish: () => request.publish(journal),
|
||||
...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}),
|
||||
now: () => request.now()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export function mutateStructuredAgentSession<TValue>(
|
||||
admitAndRunAgentSessionMutation({
|
||||
store: context.deps.store,
|
||||
adapter: context.deps.adapter,
|
||||
agents: context.deps.agents,
|
||||
logger: context.deps.logger,
|
||||
callerKey: caller.callerKey,
|
||||
envelope,
|
||||
|
||||
+5
-1
@@ -18,6 +18,7 @@ import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_THREAD as THREAD
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition'
|
||||
|
||||
let host: StructuredAgentSessionHost
|
||||
let acquire: Mock<StructuredAgentSessionAdapter['acquire']>
|
||||
@@ -89,7 +90,10 @@ describe('a mutation while an import is owed', () => {
|
||||
it('a goal set sees the goal and the turn the provider wrote, and replaces it in that turn', async () => {
|
||||
await attach()
|
||||
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
|
||||
Object.assign(host.deps.adapter, { changeThreadGoal, supportsThreadGoal: () => true })
|
||||
Object.assign(host.deps.adapter, {
|
||||
changeThreadGoal,
|
||||
capabilities: () => CODEX_STRUCTURED_AGENT.capabilities
|
||||
})
|
||||
const owed = oweImport()
|
||||
providerOpensTurn('turn-g', 901)
|
||||
providerEvents().appendItem(
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// The context an admitted mutation's plan runs in, built from the request that admitted it.
|
||||
|
||||
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'
|
||||
|
||||
export function mutationTurnContext<TValue>(
|
||||
request: AgentSessionMutationRequest<TValue>,
|
||||
journal: AgentSessionJournal,
|
||||
record: AgentSessionRecord
|
||||
): AgentSessionTurnContext {
|
||||
const fence = record.lease.runtimeFence
|
||||
const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options
|
||||
return {
|
||||
sessionId: request.envelope.sessionId,
|
||||
journal,
|
||||
fence,
|
||||
adapter: request.adapter,
|
||||
agents: request.agents,
|
||||
agent: record.provider,
|
||||
logger: request.logger,
|
||||
...(persistedOptions ? { persistedOptions } : {}),
|
||||
persistOptions: (options) =>
|
||||
request.store
|
||||
.replaceSessionOptions({
|
||||
sessionId: request.envelope.sessionId,
|
||||
fence,
|
||||
options,
|
||||
now: request.now()
|
||||
})
|
||||
.then(() => undefined),
|
||||
resolvedBy: request.callerKey,
|
||||
publish: () => request.publish(journal),
|
||||
...(request.providerChildPhase ? { providerChildPhase: request.providerChildPhase } : {}),
|
||||
now: () => request.now()
|
||||
}
|
||||
}
|
||||
+2
@@ -23,6 +23,7 @@ import {
|
||||
hostTestMessage
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const relaunchedRoots: string[] = []
|
||||
|
||||
@@ -80,6 +81,7 @@ async function relaunch(): Promise<StructuredAgentSessionHost> {
|
||||
})
|
||||
const store = await openTestAgentSessionRecordStore(relaunched)
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
+3
@@ -19,6 +19,7 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns'
|
||||
import { sendPlan } from './structured-agent-session-mutation-plans'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
async function context(): Promise<AgentSessionTurnContext> {
|
||||
return {
|
||||
@@ -35,6 +36,8 @@ async function context(): Promise<AgentSessionTurnContext> {
|
||||
stateDirectory: join(hostTestState().root, 'settlement')
|
||||
}),
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: adapter(),
|
||||
persistOptions: async () => {},
|
||||
resolvedBy: 'test',
|
||||
|
||||
+7
-3
@@ -6,6 +6,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
|
||||
import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start'
|
||||
import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read'
|
||||
import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support'
|
||||
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
|
||||
const SESSION = 'session-1'
|
||||
@@ -44,9 +45,12 @@ describe('an option picked while the chat is at rest', () => {
|
||||
const persistOptions = vi.fn(async () => {})
|
||||
const refused = await recordStructuredAgentSessionOptionIntent(
|
||||
{
|
||||
getRecord: () =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options.
|
||||
({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord
|
||||
store: {
|
||||
getRecord: () =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options.
|
||||
({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord
|
||||
},
|
||||
agents: claudeAndCodexAgents()
|
||||
},
|
||||
{ sessionId: SESSION, persistOptions, publish: () => {} },
|
||||
{ key: 'notAnOption', value: 'x' }
|
||||
|
||||
+2
@@ -23,6 +23,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const DEFAULT_MODEL = 'gpt-default'
|
||||
@@ -126,6 +127,7 @@ beforeEach(async () => {
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
router = adapter()
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: router,
|
||||
|
||||
+7
-1
@@ -9,6 +9,7 @@ import {
|
||||
} from '../agent-model-catalog/agent-model-catalog-store'
|
||||
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
|
||||
import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const SESSION = 'session-1'
|
||||
|
||||
@@ -35,7 +36,12 @@ describe('options at rest', () => {
|
||||
const resting = { child: null, params: { provider: 'codex' } }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
|
||||
const context = {
|
||||
deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog },
|
||||
deps: {
|
||||
adapter: {},
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
store: { getRecord: () => record },
|
||||
modelCatalog
|
||||
},
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
openConversation: async () => resting,
|
||||
conversation: async () => resting
|
||||
|
||||
@@ -7,17 +7,15 @@
|
||||
|
||||
import {
|
||||
refuse,
|
||||
type AgentSessionModelOption,
|
||||
type AgentSessionOptionResult,
|
||||
type AgentSessionOptionsResult
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { claudeFallbackModelOptions } from '../../claude/claude-structured-session-options'
|
||||
import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure'
|
||||
import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options'
|
||||
import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start'
|
||||
import type { StructuredAgentDefinition } from './structured-agent-definition'
|
||||
import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
@@ -25,28 +23,28 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s
|
||||
|
||||
type RestingOptions = Pick<AgentSessionOptionsResult, 'models' | 'fastModeSupport' | 'current'>
|
||||
|
||||
/** With no catalog for the account, the list a running child falls back to: Claude's built-in
|
||||
* models. A Codex child has no such list, so none: the client fills the current model from its
|
||||
* own unknown-model defaults, unchanged from before this list was shared. */
|
||||
function restingFallbackModels(
|
||||
provider: AgentSessionRecord['provider']
|
||||
): AgentSessionModelOption[] | null {
|
||||
return provider === 'claude' ? claudeFallbackModelOptions() : null
|
||||
/** The at-rest rules of the record's agent, as this runtime registered it; null for any other. */
|
||||
function restingOptionRules(
|
||||
agents: Pick<StructuredAgentRegistry, 'definition'>,
|
||||
record: AgentSessionRecord
|
||||
): StructuredAgentDefinition['restingOptions'] | null {
|
||||
return agents.definition(record.provider)?.restingOptions ?? null
|
||||
}
|
||||
|
||||
async function readStructuredAgentSessionOptionsAtRest(
|
||||
deps: Pick<StructuredAgentSessionHostDeps, 'store' | 'modelCatalog'>,
|
||||
deps: Pick<StructuredAgentSessionHostDeps, 'store' | 'agents' | 'modelCatalog'>,
|
||||
sessionId: string
|
||||
): Promise<RestingOptions> {
|
||||
const record = deps.store.getRecord(sessionId)
|
||||
if (!record) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
const rules = restingOptionRules(deps.agents, record)
|
||||
const catalog = (await deps.modelCatalog
|
||||
?.read({ agent: record.provider, sessionId })
|
||||
.catch(() => null)) ?? { origin: 'unknown' as const }
|
||||
const listed =
|
||||
catalog.origin === 'unknown' ? restingFallbackModels(record.provider) : catalog.models
|
||||
// With no catalog for the account, the list a running child of this agent falls back to.
|
||||
const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models
|
||||
const models = listed ?? []
|
||||
const saved = record.options ?? {}
|
||||
const fastMode =
|
||||
@@ -59,11 +57,10 @@ async function readStructuredAgentSessionOptionsAtRest(
|
||||
saved.model ??
|
||||
(catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) ??
|
||||
''
|
||||
// As a live child answers: the pick, else what Claude runs for this model when none is sent.
|
||||
// A live Codex child answers only the effort its thread reported, never the model's default.
|
||||
// As a live child answers: the pick, else the model's default where the agent reports that.
|
||||
const effort =
|
||||
saved.effort ??
|
||||
(record.provider === 'claude'
|
||||
(rules?.effortDefaultsToModel
|
||||
? models.find((entry) => entry.id === model)?.defaultEffort
|
||||
: undefined)
|
||||
return {
|
||||
@@ -81,16 +78,15 @@ async function readStructuredAgentSessionOptionsAtRest(
|
||||
|
||||
/** Records a pick for the next start. Only a key the provider would accept is kept. */
|
||||
export async function recordStructuredAgentSessionOptionIntent(
|
||||
store: Pick<AgentSessionRecordStore, 'getRecord'>,
|
||||
deps: {
|
||||
store: Pick<AgentSessionRecordStore, 'getRecord'>
|
||||
agents: Pick<StructuredAgentRegistry, 'definition'>
|
||||
},
|
||||
ctx: Pick<AgentSessionTurnContext, 'sessionId' | 'persistOptions' | 'publish'>,
|
||||
input: { key: string; value: string }
|
||||
): Promise<TurnOutcome<AgentSessionOptionResult>> {
|
||||
const record = store.getRecord(ctx.sessionId)
|
||||
const accepted =
|
||||
record?.provider === 'codex'
|
||||
? isCodexTurnOptionKey(input.key)
|
||||
: record?.provider === 'claude' && isClaudeStructuredOptionKey(input.key)
|
||||
if (!record || !accepted) {
|
||||
const record = deps.store.getRecord(ctx.sessionId)
|
||||
if (!record || !restingOptionRules(deps.agents, record)?.acceptsKey(input.key)) {
|
||||
return {
|
||||
ok: false,
|
||||
refusal: refuse(
|
||||
@@ -114,7 +110,7 @@ export async function readStructuredAgentSessionOptions(
|
||||
>,
|
||||
sessionId: string
|
||||
): Promise<AgentSessionOptionsResult> {
|
||||
const { adapter, store } = context.deps
|
||||
const { adapter, agents, store } = context.deps
|
||||
const live = await context.serialize(sessionId, async () => {
|
||||
const session = await context.openConversation(sessionId).catch((error: unknown) => {
|
||||
throw journalOpenReadRefusal(error, context.deps.logger, sessionId)
|
||||
@@ -133,6 +129,7 @@ export async function readStructuredAgentSessionOptions(
|
||||
const session = await context.conversation(sessionId)
|
||||
const phase = store.getRecord(sessionId)?.rewind?.phase
|
||||
const agent = session.params.provider
|
||||
const capabilities = agents.capabilities(agent)
|
||||
return {
|
||||
...options,
|
||||
rewind:
|
||||
@@ -142,11 +139,9 @@ export async function readStructuredAgentSessionOptions(
|
||||
supported: false,
|
||||
reason: 'unsupported'
|
||||
}),
|
||||
conversationCommands: adapter.compact ? ['clear', 'compact'] : ['clear'],
|
||||
...(adapter.supportsThreadGoal?.(sessionId, agent)
|
||||
? { threadGoal: { current: session.journal.threadGoal() } }
|
||||
: {}),
|
||||
...(adapter.recordsContextUsage?.(sessionId, agent)
|
||||
conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'],
|
||||
...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}),
|
||||
...(capabilities?.contextUsage
|
||||
? { contextUsage: { current: session.journal.contextUsage() } }
|
||||
: {})
|
||||
}
|
||||
|
||||
+2
@@ -33,6 +33,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const SWEEP_MS = 5
|
||||
@@ -94,6 +95,7 @@ beforeEach(async () => {
|
||||
})
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: Object.assign(adapter, { supportsCreate: () => true }),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l
|
||||
import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel'
|
||||
import type { AgentSessionPromptCancelRoute } from './structured-agent-session-adapter-stop'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
@@ -91,6 +92,8 @@ function context(
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter,
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'client-1',
|
||||
|
||||
+2
@@ -42,6 +42,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), {
|
||||
@@ -85,6 +86,7 @@ const spawnStartingChild: StructuredAgentSessionAdapter['acquire'] = async (inpu
|
||||
|
||||
function startHost(): void {
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -18,6 +18,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure-
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CLAUDE_SESSION = 'claude-session'
|
||||
const hosts: StructuredAgentSessionHost[] = []
|
||||
@@ -58,6 +59,7 @@ function createHost(
|
||||
probeOwner?: StructuredAgentSessionHost['deps']['probeOwner']
|
||||
): StructuredAgentSessionHost {
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: claudeAdapter(),
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const INIT_DELAY_MS = 40
|
||||
@@ -76,6 +77,7 @@ beforeEach(async () => {
|
||||
})
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
// The production router is what declares create support; the bare adapter only knows locations.
|
||||
|
||||
+4
-6
@@ -12,10 +12,8 @@ export function adapterSupportsCreate(
|
||||
if (adapter.supportsCreate) {
|
||||
return adapter.supportsCreate(location, agent)
|
||||
}
|
||||
if (agent !== 'codex') {
|
||||
return false
|
||||
}
|
||||
// Older Codex adapters exposed only location support; absence still fails closed here.
|
||||
// An adapter with no per-agent gate serves the one agent it was built for, so only its location
|
||||
// support can refuse; absence still fails closed here.
|
||||
return adapter.supportsLocation?.(location) ?? false
|
||||
}
|
||||
|
||||
@@ -38,6 +36,6 @@ export function adapterSupportsRecord(
|
||||
if (adapter.supportsCreate) {
|
||||
return adapter.supportsCreate(record.location, record.provider)
|
||||
}
|
||||
// Old Codex records stay readable unless the adapter explicitly rejects their location.
|
||||
return record.provider === 'codex' && (adapter.supportsLocation?.(record.location) ?? true)
|
||||
// A record stays readable unless the adapter explicitly rejects its location.
|
||||
return adapter.supportsLocation?.(record.location) ?? true
|
||||
}
|
||||
|
||||
+3
-5
@@ -29,6 +29,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
export const QUEUED_RIG_CALLER = { callerKey: 'client-1' }
|
||||
type RigSendOptions = { internal?: true; source?: AgentMessageSource }
|
||||
@@ -76,6 +77,7 @@ export async function createQueuedMessageTestRig(
|
||||
const store = await openTestAgentSessionRecordStore(root)
|
||||
const makeHost = () =>
|
||||
new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexDeclared(),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
@@ -133,11 +135,7 @@ export async function createQueuedMessageTestRig(
|
||||
sessionId,
|
||||
clientOperationId,
|
||||
expectedRuntimeFence: 1,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method,
|
||||
sessionId,
|
||||
fields
|
||||
})
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -83,6 +84,7 @@ function adapter(): StructuredAgentSessionAdapter {
|
||||
|
||||
function openHost(overrides: Partial<StructuredAgentSessionHostDeps> = {}): void {
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const METHODS = ['agentSession.setOption', 'agentSession.send'] as const
|
||||
@@ -90,6 +91,7 @@ async function createHarness(options: { attached?: boolean } = {}) {
|
||||
setOption
|
||||
}
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter,
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// The registry is the only source of an agent's definition: routing, declared capabilities and the
|
||||
// option rules a chat at rest reads all follow what composition registered, and a definition this
|
||||
// build ships but did not register decides nothing.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture'
|
||||
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition'
|
||||
import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition'
|
||||
import type { StructuredAgentDefinition } from './structured-agent-definition'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router'
|
||||
import { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
|
||||
import {
|
||||
readStructuredAgentSessionOptions,
|
||||
recordStructuredAgentSessionOptionIntent
|
||||
} from './structured-agent-session-options-read'
|
||||
|
||||
const RECORD: AgentSessionRecord = {
|
||||
...agentSessionRecordFixture(),
|
||||
options: { model: 'pilot-model' }
|
||||
}
|
||||
|
||||
/** Claude, registered with rules unlike the ones Claude's own module declares. */
|
||||
const NON_DEFAULT: StructuredAgentDefinition = {
|
||||
...CLAUDE_STRUCTURED_AGENT,
|
||||
capabilities: { ...CLAUDE_STRUCTURED_AGENT.capabilities, threadGoal: true },
|
||||
restingOptions: {
|
||||
acceptsKey: (key) => key === 'pilotOption',
|
||||
fallbackModels: () => [
|
||||
{ id: 'pilot-model', label: 'Pilot', isDefault: true, defaultEffort: 'low', efforts: [] }
|
||||
],
|
||||
effortDefaultsToModel: true
|
||||
}
|
||||
}
|
||||
|
||||
function fakeAdapter(): StructuredAgentSessionAdapter {
|
||||
return {
|
||||
acquire: vi.fn(async ({ fence, spawnToken }) => ({
|
||||
process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken },
|
||||
link: {
|
||||
linkId: `link-${fence}`,
|
||||
handle: claudeProviderHandle('provider-session-1', null),
|
||||
origin: 'created' as const,
|
||||
mintedAtFence: fence,
|
||||
observedAt: 1
|
||||
}
|
||||
})),
|
||||
dispatch: vi.fn(),
|
||||
cancelTurn: vi.fn(),
|
||||
answerPrompt: vi.fn(),
|
||||
setOption: vi.fn(),
|
||||
compact: vi.fn(),
|
||||
changeThreadGoal: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function pick(agents: StructuredAgentRegistry, record: AgentSessionRecord | null, key: string) {
|
||||
const persistOptions = vi.fn(async () => {})
|
||||
const result = recordStructuredAgentSessionOptionIntent(
|
||||
{ store: { getRecord: () => record }, agents },
|
||||
{ sessionId: RECORD.sessionId, persistOptions, publish: vi.fn() },
|
||||
{ key, value: 'enabled' }
|
||||
)
|
||||
return { result, persistOptions }
|
||||
}
|
||||
|
||||
function readAtRest(agents: StructuredAgentRegistry) {
|
||||
const resting = {
|
||||
child: null,
|
||||
params: { provider: RECORD.provider },
|
||||
journal: { threadGoal: () => null, contextUsage: () => null }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
|
||||
const context = {
|
||||
deps: {
|
||||
adapter: new StructuredAgentSessionAdapterRouter(agents, async () => {}),
|
||||
agents,
|
||||
store: { getRecord: () => RECORD }
|
||||
},
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
openConversation: async () => resting,
|
||||
conversation: async () => resting
|
||||
} as unknown as StructuredAgentSessionMutationContext
|
||||
return readStructuredAgentSessionOptions(context, RECORD.sessionId)
|
||||
}
|
||||
|
||||
describe('a registered definition', () => {
|
||||
it('routes, declares capabilities and rules options at rest for its agent', async () => {
|
||||
const adapter = fakeAdapter()
|
||||
const agents = new StructuredAgentRegistry([{ definition: NON_DEFAULT, adapter }])
|
||||
const router = new StructuredAgentSessionAdapterRouter(agents, async () => {})
|
||||
|
||||
await router.acquire({
|
||||
identity: {
|
||||
sessionId: 'session-routed',
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'claude',
|
||||
providerHandle: null
|
||||
},
|
||||
fence: 1,
|
||||
spawnToken: 'spawn-1'
|
||||
})
|
||||
expect(adapter.acquire).toHaveBeenCalledOnce()
|
||||
expect(agents.capabilities(RECORD.provider)).toBe(NON_DEFAULT.capabilities)
|
||||
|
||||
const accepted = pick(agents, RECORD, 'pilotOption')
|
||||
await expect(accepted.result).resolves.toMatchObject({ ok: true })
|
||||
expect(accepted.persistOptions).toHaveBeenCalledWith({
|
||||
model: 'pilot-model',
|
||||
pilotOption: 'enabled'
|
||||
})
|
||||
// Claude's own module accepts `model`; the registration says otherwise and wins.
|
||||
const refused = pick(agents, RECORD, 'model')
|
||||
await expect(refused.result).resolves.toMatchObject({ ok: false })
|
||||
|
||||
const options = await readAtRest(agents)
|
||||
expect(options.models.map((model) => model.id)).toEqual(['pilot-model'])
|
||||
// No pick for effort: the registered rules read the model's default.
|
||||
expect(options.current).toEqual({ model: 'pilot-model', effort: 'low' })
|
||||
expect(options.threadGoal).toEqual({ current: null })
|
||||
})
|
||||
|
||||
it('leaves an agent this runtime did not register with no rules, whatever the build ships', async () => {
|
||||
const agents = new StructuredAgentRegistry([
|
||||
{
|
||||
definition: CODEX_STRUCTURED_AGENT,
|
||||
adapter: { ...fakeAdapter(), rewind: vi.fn(), recoverRewind: vi.fn() }
|
||||
}
|
||||
])
|
||||
|
||||
expect(agents.definition(RECORD.provider)).toBeNull()
|
||||
expect(agents.capabilities(RECORD.provider)).toBeNull()
|
||||
const refused = pick(agents, RECORD, 'model')
|
||||
await expect(refused.result).resolves.toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } }
|
||||
})
|
||||
expect(refused.persistOptions).not.toHaveBeenCalled()
|
||||
const options = await readAtRest(agents)
|
||||
expect(options.models).toEqual([])
|
||||
expect(options.current).toEqual({ model: 'pilot-model' })
|
||||
expect(options.conversationCommands).toEqual(['clear'])
|
||||
})
|
||||
|
||||
it('refuses a pick for a session with no record, as before', async () => {
|
||||
const agents = new StructuredAgentRegistry([
|
||||
{ definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() }
|
||||
])
|
||||
const missing = pick(agents, null, 'model')
|
||||
await expect(missing.result).resolves.toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_operation_invalid' }
|
||||
})
|
||||
expect(missing.persistOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+24
-22
@@ -24,6 +24,7 @@ import {
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -43,31 +44,32 @@ beforeEach(async () => {
|
||||
stopBackgroundTasks = vi.fn(async () => ({ cancelled: true }))
|
||||
cancelTurn = vi.fn(async () => ({ cancelled: true }))
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
const adapter: StructuredAgentSessionAdapter = {
|
||||
acquire: async ({ fence, spawnToken }) => ({
|
||||
process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken },
|
||||
acquisitionGeneration: 'generation-1',
|
||||
link: {
|
||||
linkId: `link-${fence}`,
|
||||
handle: codexProviderHandle(THREAD),
|
||||
origin: fence > 1 ? ('resumed' as const) : ('created' as const),
|
||||
mintedAtFence: fence,
|
||||
observedAt: NOW
|
||||
}
|
||||
}),
|
||||
dispatch: vi.fn(async () => ({ state: 'admitted' as const })),
|
||||
closeSession: vi.fn(async () => true),
|
||||
releaseAcquisition: vi.fn(async () => true),
|
||||
cancelTurn,
|
||||
answerPrompt: vi.fn(async () => undefined),
|
||||
setOption,
|
||||
changeThreadGoal,
|
||||
stopBackgroundTasks
|
||||
}
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexAgents(adapter),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
acquire: async ({ fence, spawnToken }) => ({
|
||||
process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken },
|
||||
acquisitionGeneration: 'generation-1',
|
||||
link: {
|
||||
linkId: `link-${fence}`,
|
||||
handle: codexProviderHandle(THREAD),
|
||||
origin: fence > 1 ? ('resumed' as const) : ('created' as const),
|
||||
mintedAtFence: fence,
|
||||
observedAt: NOW
|
||||
}
|
||||
}),
|
||||
dispatch: vi.fn(async () => ({ state: 'admitted' as const })),
|
||||
closeSession: vi.fn(async () => true),
|
||||
releaseAcquisition: vi.fn(async () => true),
|
||||
cancelTurn,
|
||||
answerPrompt: vi.fn(async () => undefined),
|
||||
setOption,
|
||||
changeThreadGoal,
|
||||
supportsThreadGoal: () => true,
|
||||
stopBackgroundTasks
|
||||
},
|
||||
adapter,
|
||||
journalDatabase: openTestJournalHostDatabase(root),
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-1',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
HOST_TEST_THREAD as THREAD
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { startAgent } from './structured-agent-session-restart-interruption-test-harness'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const REQUESTED = 'Cancellation requested.'
|
||||
|
||||
@@ -112,6 +113,7 @@ describe('a Stop pressed again', () => {
|
||||
await store.renewLeases([])
|
||||
const relaunchedStore = await openTestAgentSessionRecordStore(root)
|
||||
const relaunched = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store: relaunchedStore,
|
||||
adapter: adapter(),
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
StructuredAgentSessionAdapter
|
||||
} from './structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support'
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
import {
|
||||
HOST_TEST_NOW,
|
||||
@@ -162,6 +163,7 @@ export async function createRestTestRig(
|
||||
}
|
||||
const hostFor = (overrides: Partial<StructuredAgentSessionHostDeps>) =>
|
||||
new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexAgents(),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
/** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a
|
||||
* running child before its next step. Nothing else starts one ahead of a send. */
|
||||
@@ -132,6 +133,7 @@ export async function interruptedRestart(
|
||||
const clock = { now: NOW + 1 }
|
||||
const log = recordingProductionStructuredAgentSessionLogger()
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: log.logger,
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -29,6 +29,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -59,6 +60,7 @@ function adapter(): StructuredAgentSessionAdapter {
|
||||
|
||||
function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost {
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const caller = { callerKey: 'desktop' }
|
||||
const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 }
|
||||
@@ -80,6 +81,7 @@ function adapter(): StructuredAgentSessionAdapter {
|
||||
|
||||
function openHost(): StructuredAgentSessionHost {
|
||||
return new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexDeclared(),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const caller = { callerKey: 'desktop' }
|
||||
let directory: string
|
||||
@@ -97,6 +98,7 @@ beforeEach(async () => {
|
||||
closeSession: async () => true
|
||||
}
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: claudeAndCodexDeclared(),
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter,
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function rewindStructuredAgentSession(
|
||||
const result = await admitAndRunAgentSessionMutation<AgentSessionRewindResult>({
|
||||
store,
|
||||
adapter: context.deps.adapter,
|
||||
agents: context.deps.agents,
|
||||
logger: context.deps.logger,
|
||||
callerKey: caller.callerKey,
|
||||
envelope: params.envelope,
|
||||
@@ -64,7 +65,7 @@ export async function rewindStructuredAgentSession(
|
||||
run: async (ctx) => {
|
||||
await attachContext.runtimeState.flushEventSink(sessionId)
|
||||
const record = store.getRecord(sessionId)!
|
||||
const support = ctx.adapter.rewindSupport?.(sessionId)
|
||||
const support = ctx.adapter.rewindSupport?.(sessionId, ctx.agent)
|
||||
if (!support?.supported) {
|
||||
return rewindRefusal(support?.reason ?? 'unsupported')
|
||||
}
|
||||
|
||||
+5
@@ -12,6 +12,7 @@ import { performSend, type AgentSessionTurnContext } from './structured-agent-se
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
@@ -63,6 +64,8 @@ describe('structured send idempotency', () => {
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 2,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: { dispatch } as unknown as StructuredAgentSessionAdapter,
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'caller',
|
||||
@@ -104,6 +107,8 @@ describe('structured send idempotency', () => {
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: { dispatch } as unknown as StructuredAgentSessionAdapter,
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'caller',
|
||||
|
||||
+2
@@ -26,6 +26,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */
|
||||
function eventually(assertion: () => unknown): Promise<unknown> {
|
||||
@@ -71,6 +72,7 @@ async function relaunchAfterCrashMidTurn(
|
||||
throw new Error('claude: command not found')
|
||||
})
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: { ...adapter(), acquire },
|
||||
|
||||
+2
@@ -26,6 +26,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -72,6 +73,7 @@ beforeEach(async () => {
|
||||
}))
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: {
|
||||
warn: (_message, fields) => hostErrors.push(fields.error),
|
||||
error: (_message, fields) => hostErrors.push(fields.error)
|
||||
|
||||
+2
@@ -31,6 +31,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -148,6 +149,7 @@ beforeEach(async () => {
|
||||
dispatch = vi.fn(async () => ({ state: 'admitted' as const }))
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+7
@@ -28,6 +28,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
|
||||
@@ -109,6 +110,7 @@ beforeEach(async () => {
|
||||
dispatch = vi.fn(async () => accepted())
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
@@ -138,6 +140,7 @@ describe('settled attach retry', () => {
|
||||
throw new Error('journal path unavailable')
|
||||
})
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
@@ -196,6 +199,7 @@ describe('settled attach retry', () => {
|
||||
})
|
||||
const mintSpawnToken = vi.fn(() => 'spawn-safe')
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
@@ -241,6 +245,7 @@ describe('settled attach retry', () => {
|
||||
let token = 0
|
||||
const mintSpawnToken = vi.fn(() => `spawn-${++token}`)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
@@ -272,6 +277,7 @@ describe('settled attach retry', () => {
|
||||
await host.flushAllStreamedEvents()
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
@@ -330,6 +336,7 @@ describe('settled attach retry', () => {
|
||||
await host.flushAllStreamedEvents()
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
+2
@@ -29,6 +29,7 @@ import {
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI'
|
||||
@@ -122,6 +123,7 @@ beforeEach(async () => {
|
||||
)
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
+2
@@ -30,6 +30,7 @@ import {
|
||||
hostTestMessage
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const writes = vi.hoisted(() => ({ failing: false }))
|
||||
|
||||
@@ -84,6 +85,7 @@ async function relaunch(
|
||||
// The lease-reconcile entries the host logs, by the failure each reports.
|
||||
const leaseReconcileLogged = vi.fn()
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: {
|
||||
warn: (_message, fields) => {
|
||||
if (fields.scope === 'lease-reconcile') {
|
||||
|
||||
+3
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns'
|
||||
@@ -48,6 +49,8 @@ async function stopWhileTheTurnOpens(
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: {
|
||||
acquire: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
|
||||
+3
@@ -16,6 +16,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand
|
||||
import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
@@ -76,6 +77,8 @@ async function stopEndingTheChild(options: {
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: {
|
||||
acquire: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
|
||||
+2
@@ -42,6 +42,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const UNEXPECTED_PROVIDER_EXIT_OUTCOME =
|
||||
'Codex stopped while this response was in progress. You can continue in this conversation.'
|
||||
@@ -77,6 +78,7 @@ function openHost(
|
||||
probeOwner?: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
|
||||
): void {
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: {
|
||||
warn: (_message, fields) => hostErrors.push(fields.error),
|
||||
error: (_message, fields) => hostErrors.push(fields.error)
|
||||
|
||||
+27
-17
@@ -16,6 +16,7 @@ import {
|
||||
threadGoalPlan
|
||||
} from './structured-agent-session-thread-goal'
|
||||
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
|
||||
import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
|
||||
@@ -66,15 +67,19 @@ function appendGoalRow(
|
||||
|
||||
function context(
|
||||
journal: AgentSessionJournal,
|
||||
adapter: Partial<StructuredAgentSessionAdapter>
|
||||
double: Partial<StructuredAgentSessionAdapter>,
|
||||
agent = 'codex'
|
||||
): AgentSessionTurnContext {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only the goal methods.
|
||||
const adapter = double as StructuredAgentSessionAdapter
|
||||
return {
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only the goal methods.
|
||||
adapter: adapter as StructuredAgentSessionAdapter,
|
||||
adapter,
|
||||
agents: claudeAndCodexAgents(adapter),
|
||||
agent,
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'client-1',
|
||||
publish: vi.fn(),
|
||||
@@ -88,7 +93,9 @@ describe('performThreadGoalChange', () => {
|
||||
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
|
||||
|
||||
const result = await performThreadGoalChange(
|
||||
context(journal, { changeThreadGoal, supportsThreadGoal: () => true }),
|
||||
context(journal, {
|
||||
changeThreadGoal
|
||||
}),
|
||||
{ clientOperationId: 'op-1', change: { kind: 'set', objective: 'Ship the parser' } }
|
||||
)
|
||||
|
||||
@@ -116,8 +123,7 @@ describe('performThreadGoalChange', () => {
|
||||
it('removes the objective when the provider refuses the goal', async () => {
|
||||
const journal = await openJournal()
|
||||
const ctx = context(journal, {
|
||||
changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' }),
|
||||
supportsThreadGoal: () => true
|
||||
changeThreadGoal: async () => ({ ok: false, rejected: 'goals feature is disabled' })
|
||||
})
|
||||
|
||||
const result = await performThreadGoalChange(ctx, {
|
||||
@@ -141,8 +147,7 @@ describe('performThreadGoalChange', () => {
|
||||
const ctx = context(journal, {
|
||||
changeThreadGoal: async () => {
|
||||
throw new Error('connection closed')
|
||||
},
|
||||
supportsThreadGoal: () => true
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -163,8 +168,7 @@ describe('performThreadGoalChange', () => {
|
||||
async () => ({ ok: true as const })
|
||||
]
|
||||
const ctx = context(journal, {
|
||||
changeThreadGoal: () => attempts.shift()!(),
|
||||
supportsThreadGoal: () => true
|
||||
changeThreadGoal: () => attempts.shift()!()
|
||||
})
|
||||
const input = {
|
||||
clientOperationId: 'op-9',
|
||||
@@ -192,7 +196,9 @@ describe('performThreadGoalChange', () => {
|
||||
it('journals nothing for a status change or clear', async () => {
|
||||
const journal = await openJournal()
|
||||
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
|
||||
const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true })
|
||||
const ctx = context(journal, {
|
||||
changeThreadGoal
|
||||
})
|
||||
|
||||
await performThreadGoalChange(ctx, {
|
||||
clientOperationId: 'op-3',
|
||||
@@ -208,10 +214,10 @@ describe('performThreadGoalChange', () => {
|
||||
const journal = await openJournal()
|
||||
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
|
||||
|
||||
const result = await performThreadGoalChange(
|
||||
context(journal, { changeThreadGoal, supportsThreadGoal: () => false }),
|
||||
{ clientOperationId: 'op-5', change: { kind: 'set', objective: 'Ship it' } }
|
||||
)
|
||||
const result = await performThreadGoalChange(context(journal, { changeThreadGoal }, 'claude'), {
|
||||
clientOperationId: 'op-5',
|
||||
change: { kind: 'set', objective: 'Ship it' }
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
@@ -232,7 +238,9 @@ describe('performThreadGoalChange', () => {
|
||||
const journal = await openJournal()
|
||||
await appendGoalRow(journal, { status: 'complete' })
|
||||
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
|
||||
const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true })
|
||||
const ctx = context(journal, {
|
||||
changeThreadGoal
|
||||
})
|
||||
|
||||
await performThreadGoalChange(ctx, {
|
||||
clientOperationId: 'op-6',
|
||||
@@ -262,7 +270,9 @@ describe('performThreadGoalChange', () => {
|
||||
it('reads a goal the provider reported, still landing when the set arrives, as the one it replaces', async () => {
|
||||
const journal = await openJournal()
|
||||
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
|
||||
const ctx = context(journal, { changeThreadGoal, supportsThreadGoal: () => true })
|
||||
const ctx = context(journal, {
|
||||
changeThreadGoal
|
||||
})
|
||||
|
||||
// Issued, not yet landed: the set's read takes its place behind it in the journal's queue.
|
||||
const landing = appendGoalRow(journal, { status: 'active' })
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function performThreadGoalChange(
|
||||
ctx: AgentSessionTurnContext,
|
||||
input: { clientOperationId: string; change: AgentSessionThreadGoalChange }
|
||||
): Promise<TurnOutcome<AgentSessionThreadGoalResult>> {
|
||||
if (!ctx.adapter.changeThreadGoal || !ctx.adapter.supportsThreadGoal?.(ctx.sessionId)) {
|
||||
if (!ctx.adapter.changeThreadGoal || !ctx.agents.capabilities(ctx.agent)?.threadGoal) {
|
||||
return refused('goalsUnsupported', 'Goals are unavailable for this chat session.')
|
||||
}
|
||||
const { change } = input
|
||||
|
||||
@@ -13,6 +13,7 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { testEventSinkLogging } from './structured-agent-session-logger-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
@@ -58,6 +59,8 @@ describe('performCancel', () => {
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter,
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'client-1',
|
||||
@@ -112,6 +115,8 @@ describe('performCancel', () => {
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter,
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'client-1',
|
||||
@@ -157,6 +162,8 @@ describe('performCancel', () => {
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: {
|
||||
cancelTurn: vi.fn(async () => ({ cancelled: false }))
|
||||
} as unknown as StructuredAgentSessionAdapter,
|
||||
@@ -191,6 +198,8 @@ describe('performCancel', () => {
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter,
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'client-1',
|
||||
@@ -229,6 +238,8 @@ describe('performCancel', () => {
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter,
|
||||
persistOptions: async () => undefined,
|
||||
resolvedBy: 'client-1',
|
||||
@@ -310,6 +321,8 @@ describe('what a conversation Stop reports when the provider stopped nothing', (
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: {
|
||||
acquire: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
@@ -393,6 +406,8 @@ describe('the note a Stop writes', () => {
|
||||
sessionId: 'session-1',
|
||||
journal,
|
||||
fence: 1,
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
agent: 'codex',
|
||||
adapter: {
|
||||
acquire: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
journalOpenRefusal
|
||||
} from '../agent-session-journal/journal-open-failure'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
export { performSetOption } from './structured-agent-session-turns-options'
|
||||
export { performPrompt } from './structured-agent-session-turns-prompt'
|
||||
export { performCancel } from './structured-agent-session-turns-cancel'
|
||||
@@ -52,6 +53,10 @@ export type AgentSessionTurnContext = {
|
||||
journal: AgentSessionJournal
|
||||
fence: number
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
/** What each agent declares; the session's own answer is `agents.capabilities(agent)`. */
|
||||
agents: StructuredAgentRegistry
|
||||
/** The session's agent. */
|
||||
agent: string
|
||||
logger: StructuredAgentSessionLogger
|
||||
persistedOptions?: Readonly<Record<string, string>>
|
||||
persistOptions: (options: Readonly<Record<string, string>>) => Promise<void>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
hostTestMessage
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const relaunchedRoots: string[] = []
|
||||
|
||||
@@ -58,6 +59,7 @@ async function relaunchWith(
|
||||
edit(openTestJournalHostDatabase(relaunched).db)
|
||||
const store = await openTestAgentSessionRecordStore(relaunched)
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
|
||||
+2
@@ -26,6 +26,7 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */
|
||||
function eventually(assertion: () => unknown): Promise<unknown> {
|
||||
@@ -85,6 +86,7 @@ beforeEach(async () => {
|
||||
})
|
||||
store = await openTestAgentSessionRecordStore(root)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: Object.assign(adapter, { supportsCreate: () => true }),
|
||||
|
||||
+2
@@ -55,6 +55,7 @@ import {
|
||||
claudeProviderHandle,
|
||||
codexProviderHandle
|
||||
} from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const DEAD_OWNER: AgentSessionProcessIdentity = {
|
||||
@@ -126,6 +127,7 @@ async function seedStore(record: PersistedAgentSessionRecord): Promise<void> {
|
||||
/** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */
|
||||
function openHost(overrides: Partial<StructuredAgentSessionHostDeps> = {}): void {
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user