mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Keep native chat /clear in the same conversation (#26579)
* Keep clear in the same chat and reset its provider context * Derive clear pauses only from journal membership and cover older rewind clients * Point queued command rewind test at the structured hook * Group queue consume errors and rewind replay assertions
This commit is contained in:
@@ -196,5 +196,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
|
||||
'src/main/runtime/structured-worker-at-rest.test.ts',
|
||||
'src/main/ssh/ssh-remote-orca-cli.test.ts',
|
||||
'src/main/ssh/ssh-remote-orchestration-compatibility.test.ts',
|
||||
'tests/e2e/completed-worker-retirement-resume.unit.test.ts'
|
||||
'tests/e2e/completed-worker-retirement-resume.unit.test.ts',
|
||||
'tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts',
|
||||
'tests/e2e/cross-version-wire/agent-session-clear-old-tab-mirror.unit.test.ts'
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim
|
||||
// Real SQLite fixtures keep Node publication and close semantics.
|
||||
export const SQLITE_RUNTIME_INCLUDE = [
|
||||
...SQLITE_DATABASE_RUNTIME_INCLUDE,
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-clear-queued-command.test.ts',
|
||||
'src/main/acp/acp-structured-sign-in.test.ts',
|
||||
'src/main/acp/acp-structured-auth.test.ts',
|
||||
'src/main/acp/acp-timeline-turn-failures.test.ts',
|
||||
@@ -124,6 +125,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts',
|
||||
|
||||
@@ -95,6 +95,30 @@ describe('MobileNativeChatMessage', () => {
|
||||
const textIn = (node: ReactTestInstance): string[] =>
|
||||
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
|
||||
|
||||
it('renders the shared clear boundary as one muted divider with prior history retained', () => {
|
||||
const message = projectStructuredItemToNativeChat({
|
||||
itemId: 'clear',
|
||||
revision: 1,
|
||||
sequence: 5,
|
||||
observedAt: 5,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Context cleared',
|
||||
presentation: 'context-cleared',
|
||||
contextClear: { operationId: 'clear', afterFence: 3, clearedAt: 5 }
|
||||
}
|
||||
})!
|
||||
const tree = render(message)
|
||||
expect(textIn(tree.root)).toEqual(['Context cleared'])
|
||||
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
|
||||
expect(
|
||||
tree.root.findAll(
|
||||
(node) => String(node.type) === 'View' && node.props.style === styles.contextBoundary
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(styles.contextBoundary.borderTopColor).toBe(colors.borderSubtle)
|
||||
})
|
||||
|
||||
it.each(['system', 'user'] as const)(
|
||||
'renders a %s host notice as selectable muted text rather than a markdown answer',
|
||||
(role) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAgentSessionProviderContextBoundary } from '../../../src/shared/agent-session-provider-context'
|
||||
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
|
||||
import { memo, useCallback, useContext, useState } from 'react'
|
||||
import { Image, Text as NativeText, View } from 'react-native'
|
||||
@@ -57,6 +58,18 @@ function Prose({
|
||||
onLongPress?: () => void
|
||||
}): React.JSX.Element | null {
|
||||
if (isTextBlock(block)) {
|
||||
if (isAgentSessionProviderContextBoundary(block.contextClear)) {
|
||||
return (
|
||||
<View style={styles.contextBoundary}>
|
||||
<Text
|
||||
selectable={INLINE_TEXT_SELECTION}
|
||||
style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}
|
||||
>
|
||||
{block.text}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (isAgentSessionHostStatusPresentation(block.presentation)) {
|
||||
return (
|
||||
<Text
|
||||
|
||||
@@ -29,6 +29,11 @@ export const styles = StyleSheet.create({
|
||||
lineHeight: TEXT_SIZE + 6,
|
||||
fontWeight: '500'
|
||||
},
|
||||
contextBoundary: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
hostNotice: {
|
||||
color: colors.textMuted,
|
||||
fontSize: TEXT_SIZE,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import { ACP_LAUNCH_SPECS } from './acp-launch-specs'
|
||||
import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution'
|
||||
import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home'
|
||||
|
||||
it.each(ACP_LAUNCH_SPECS)(
|
||||
'$agent starts a new session without probing pre-clear history',
|
||||
async (spec) => {
|
||||
const base = agentSessionRecordFixture()
|
||||
const record = {
|
||||
...base,
|
||||
provider: spec.agent,
|
||||
accountHome:
|
||||
'accountLocatorKind' in spec.account.pin
|
||||
? { kind: 'opencode' as const, locator: { kind: 'unmanaged' as const } }
|
||||
: { variable: spec.account.pin.accountHomeVariable, path: '/fixture/account' },
|
||||
providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 },
|
||||
providerHandleChain: []
|
||||
}
|
||||
const account =
|
||||
spec.agent === 'opencode'
|
||||
? openCodeAcpAccountBinding(() => ({
|
||||
list: () => {
|
||||
throw new Error('launch must not change account selection')
|
||||
},
|
||||
restoreOriginalEnvironment: () => {},
|
||||
environmentForAccount: () => {
|
||||
throw new Error('unmanaged fixture must stay unmanaged')
|
||||
}
|
||||
}))
|
||||
: spec.account
|
||||
const resolve = createAcpStructuredLaunchResolver(
|
||||
{ ...spec, account },
|
||||
{
|
||||
store: { getRecord: () => record },
|
||||
readJournal: () => {
|
||||
throw new Error('pre-clear history must not be sampled')
|
||||
},
|
||||
resolveWorkspacePath: async () => '/repo/worktree',
|
||||
resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/test' }),
|
||||
resolveCommand: () => `/fixture/${spec.command}`,
|
||||
probeVersion: async () => true,
|
||||
inheritedEnv: {}
|
||||
}
|
||||
)
|
||||
const launch = await resolve({
|
||||
identity: {
|
||||
sessionId: record.sessionId,
|
||||
workspaceId: record.location.workspaceId,
|
||||
hostId: record.location.executionHostId,
|
||||
agent: spec.agent,
|
||||
providerHandle: null
|
||||
}
|
||||
})
|
||||
expect(launch.resume).toBeNull()
|
||||
expect(launch.spec.dialect).toBe(spec.dialect)
|
||||
expect(launch.args).toEqual(spec.args({ fullAccess: false, pluginDir: null }))
|
||||
}
|
||||
)
|
||||
@@ -96,6 +96,18 @@ describe('ACP launch resolution', () => {
|
||||
expect(searched[0]).toContain('/home/user/.grok-work/bin')
|
||||
})
|
||||
|
||||
it('creates a new ACP session after clear instead of probing the old session', async () => {
|
||||
const value = grokRecord({
|
||||
providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 },
|
||||
providerHandleChain: []
|
||||
})
|
||||
const readJournal = () => {
|
||||
throw new Error('old history must not be sampled for resume')
|
||||
}
|
||||
const launch = await resolver(value, false, readJournal).resolve({ identity })
|
||||
expect(launch.resume).toBeNull()
|
||||
})
|
||||
|
||||
it('asks the agent to approve everything only under full access', async () => {
|
||||
const launch = await resolver(grokRecord(), true).resolve({ identity })
|
||||
expect(launch.args).toContain('--always-approve')
|
||||
|
||||
@@ -151,7 +151,7 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) {
|
||||
const resting = {
|
||||
child: null,
|
||||
params: { provider: 'claude' },
|
||||
journal: { threadGoal: () => null, contextUsage: () => null }
|
||||
journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
|
||||
const context = {
|
||||
|
||||
@@ -155,6 +155,51 @@ describe('claude structured launch resolution', () => {
|
||||
expect(first.env).toMatchObject({ [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' })
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'starts only the clear context, recovering its saved transcript: %s',
|
||||
async (saved) => {
|
||||
const boundary = { operationId: 'clear-one', afterFence: 2, clearedAt: 100 }
|
||||
const value = record({
|
||||
providerContextBoundary: boundary,
|
||||
providerHandleChain: []
|
||||
})
|
||||
const hasTranscript = vi.fn(async () => saved)
|
||||
const launch = await resolverFor(
|
||||
value,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
hasTranscript
|
||||
)({ identity: identityAt('old-leaf') })
|
||||
const expected = claudeSessionIdForOrcaSession(SESSION_ID, boundary.operationId)
|
||||
expect(launch.providerSessionId).toBe(expected)
|
||||
expect(expected).not.toBe(claudeSessionIdForOrcaSession(SESSION_ID))
|
||||
expect(launch.continuesChain).toBe(false)
|
||||
expect(launch.resumeLeafUuid).toBeNull()
|
||||
expect(launch.resumesTranscript).toBe(saved)
|
||||
expect(launch.options).toMatchObject(saved ? { resume: expected } : { sessionId: expected })
|
||||
expect(hasTranscript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerSessionId: expected })
|
||||
)
|
||||
const retry = await resolverFor(
|
||||
value,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
hasTranscript
|
||||
)({ identity: IDENTITY })
|
||||
expect(retry.providerSessionId).toBe(expected)
|
||||
const next = await resolverFor(
|
||||
{ ...value, providerContextBoundary: { ...boundary, operationId: 'clear-two' } },
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
async () => false
|
||||
)({ identity: IDENTITY })
|
||||
expect(next.providerSessionId).not.toBe(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('resumes the durable chain head by session id and carries its leaf as bookkeeping', async () => {
|
||||
const launch = await resolverFor(
|
||||
record({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { activeProviderContext } from '../../shared/agent-session-provider-context'
|
||||
import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router'
|
||||
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
|
||||
import type {
|
||||
@@ -5,10 +6,7 @@ import type {
|
||||
PermissionMode
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import {
|
||||
agentSessionProviderHandleChainHead,
|
||||
agentSessionProviderHandleRoot
|
||||
} from '../../shared/agent-session-provider-handle'
|
||||
import { agentSessionProviderHandleRoot } from '../../shared/agent-session-provider-handle'
|
||||
import { claudeProviderHandleLeafUuid } from '../../shared/agent-session-provider-handle-encoding'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env'
|
||||
@@ -256,7 +254,8 @@ export function createClaudeStructuredLaunchResolver(
|
||||
)
|
||||
}
|
||||
// A Claude record's chain holds only Claude handles; the attach admission refuses anything else.
|
||||
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle ?? null
|
||||
const active = activeProviderContext(record)
|
||||
const head = active.head?.handle ?? null
|
||||
if (
|
||||
head &&
|
||||
(!identity.providerHandle ||
|
||||
@@ -267,7 +266,10 @@ export function createClaudeStructuredLaunchResolver(
|
||||
}
|
||||
const providerSessionId = head
|
||||
? head.nativeId
|
||||
: claudeSessionIdForOrcaSession(identity.sessionId)
|
||||
: claudeSessionIdForOrcaSession(
|
||||
identity.sessionId,
|
||||
active.pendingClear ? record.providerContextBoundary?.operationId : undefined
|
||||
)
|
||||
const continuesChain = head !== null
|
||||
const cwd = await resolveAgentSessionLaunchDirectory(deps, record)
|
||||
const sources = await resolveClaudeChildEnvSources(deps)
|
||||
@@ -288,7 +290,7 @@ export function createClaudeStructuredLaunchResolver(
|
||||
// one exits; launch that id fresh instead. With a transcript, `--session-id` would collide.
|
||||
const leafUuid = head ? claudeProviderHandleLeafUuid(head) : null
|
||||
const resumes = async (claudeConfigDir: string): Promise<boolean> =>
|
||||
head !== null &&
|
||||
(head !== null || active.pendingClear) &&
|
||||
(await claudeLaunchResumesTranscript({
|
||||
router,
|
||||
leafUuid,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** A stable UUID (v4 layout) for the Claude conversation behind an Orca chat. */
|
||||
export function claudeSessionIdForOrcaSession(sessionId: string): string {
|
||||
const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16)
|
||||
export function claudeSessionIdForOrcaSession(
|
||||
sessionId: string,
|
||||
clearOperationId?: string
|
||||
): string {
|
||||
const identity = clearOperationId
|
||||
? `orca-claude:${sessionId}:context-clear:${clearOperationId}`
|
||||
: `orca-claude:${sessionId}`
|
||||
const bytes = createHash('sha256').update(identity).digest().subarray(0, 16)
|
||||
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40
|
||||
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80
|
||||
const hex = bytes.toString('hex')
|
||||
|
||||
@@ -105,6 +105,21 @@ describe('codex structured launch resolution', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('starts a new thread after clear without resolving the old rollout', async () => {
|
||||
const resolveRollout = vi.fn(async () => '/old/rollout.jsonl')
|
||||
const value = record({
|
||||
providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 },
|
||||
providerHandleChain: []
|
||||
})
|
||||
const launch = await resolverFor(
|
||||
value,
|
||||
undefined,
|
||||
resolveRollout
|
||||
)({ identity: { ...IDENTITY, providerHandle: codexProviderHandle('old-thread') } })
|
||||
expect(launch.resumeThreadId).toBeNull()
|
||||
expect(resolveRollout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resumes a floating session in its pinned folder, not the current floating setting', async () => {
|
||||
const pinned = mkdtempSync(join(tmpdir(), 'orca-codex-floating-'))
|
||||
const resolveWorkspacePath = vi.fn(async () => '/floating/current-setting')
|
||||
|
||||
+10
-13
@@ -209,7 +209,7 @@ describe('claims written with the message', () => {
|
||||
).toEqual([UPLOAD_A, UPLOAD_B].sort())
|
||||
})
|
||||
|
||||
it("refuses a client's draft naming a gone upload, and the /clear carry claims for its new chat", async () => {
|
||||
it("refuses a gone upload and keeps a waiting card's attachment claim across clear", async () => {
|
||||
const journal = await open()
|
||||
const missing = message({ type: 'text', text: `@${join(storeRoot, UPLOAD_B, 'a.txt')}` })
|
||||
const refused = await journal.queuedMessages
|
||||
@@ -233,18 +233,15 @@ describe('claims written with the message', () => {
|
||||
hostInstance: 'p',
|
||||
requireAttachments: true
|
||||
})
|
||||
const replacement = await open('session-d')
|
||||
await replacement.queuedMessages.insert({
|
||||
messageId: 'draft-1',
|
||||
body,
|
||||
fingerprint: 'fp-d',
|
||||
hostInstance: 'p',
|
||||
carriedFrom: IDENTITY.sessionId
|
||||
})
|
||||
expect(claims()).toEqual([
|
||||
{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId },
|
||||
{ upload_id: UPLOAD_A, session_id: 'session-d' }
|
||||
])
|
||||
await journal.context.clear(
|
||||
{ operationId: 'clear', afterFence: 0, clearedAt: 1000 },
|
||||
{ write: () => {}, committed: () => {} },
|
||||
'caller-clear'
|
||||
)
|
||||
await journals.closeAll()
|
||||
const reopened = await open()
|
||||
expect(reopened.queuedMessages.get('draft-1')).toMatchObject({ body, carriedFrom: null })
|
||||
expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }])
|
||||
})
|
||||
|
||||
it("does not refuse a draft's conversion: the draft claimed its uploads when it was written", async () => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { AgentSessionProviderContextBoundary } from '../../../shared/agent-session-provider-context'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import { journalItemRowBuilder } from './journal-row-builders'
|
||||
import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer'
|
||||
import type { JournalQueuedMessages } from './journal-queued-messages'
|
||||
import type { JournalTombstoneRow } from './journal-row-schema'
|
||||
import { buildJournalQueueClearRow } from './journal-stop-and-resume-rows'
|
||||
|
||||
export function appendJournalContextClear(input: {
|
||||
state: () => JournalReducerState
|
||||
writer: JournalRowWriter
|
||||
cards: JournalQueuedMessages
|
||||
boundary: AgentSessionProviderContextBoundary
|
||||
receipt: JournalOperationReceipt
|
||||
settledByOp: string
|
||||
}) {
|
||||
const { boundary, state } = input
|
||||
let commandIds: string[] = []
|
||||
return input.writer
|
||||
.enqueueRows(
|
||||
() => {
|
||||
const cards = input.cards.list()
|
||||
commandIds = cards
|
||||
.filter(
|
||||
(card) => card.body.command && (card.state === 'waiting' || card.state === 'returned')
|
||||
)
|
||||
.map((card) => card.messageId)
|
||||
const messageIds = cards
|
||||
.filter((card) => card.state === 'waiting' && !card.body.command)
|
||||
.map((card) => card.messageId)
|
||||
return [
|
||||
journalItemRowBuilder(
|
||||
state,
|
||||
{ provider: 'orca', clientMessageId: `context-clear:${boundary.operationId}` },
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'Context cleared',
|
||||
presentation: 'context-cleared',
|
||||
contextClear: boundary
|
||||
},
|
||||
{ fence: boundary.afterFence, turnScope: { kind: 'thread' } }
|
||||
),
|
||||
(seq: number, ts: number): JournalTombstoneRow =>
|
||||
buildJournalQueueClearRow({
|
||||
state: state(),
|
||||
seq,
|
||||
ts,
|
||||
fence: boundary.afterFence,
|
||||
clear: { operationId: boundary.operationId, messageIds }
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
write: (db) => {
|
||||
input.cards.withdrawInTransaction(db, {
|
||||
messageIds: commandIds,
|
||||
settledByOp: input.settledByOp
|
||||
})
|
||||
input.receipt.write(db)
|
||||
},
|
||||
committed: input.receipt.committed
|
||||
}
|
||||
)
|
||||
.then((rows) => ({
|
||||
epoch: state().epoch,
|
||||
sequence: rows.at(-1)?.seq ?? state().lastSequence
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionProviderContextBoundary } from '../../../shared/agent-session-provider-context'
|
||||
import { latestAgentSessionContextClearSequence } from '../../../shared/agent-session-context-clear'
|
||||
import { appendJournalContextClear } from './journal-context-clear'
|
||||
import { rewindJournalContext } from './journal-context-rewind'
|
||||
import type { JournalReplacementItem } from './journal-epoch-replacement'
|
||||
import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import type { JournalQueuedMessages } from './journal-queued-messages'
|
||||
|
||||
export class JournalContextController {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
state: () => JournalReducerState
|
||||
writer: JournalRowWriter
|
||||
cards: JournalQueuedMessages
|
||||
}
|
||||
) {}
|
||||
|
||||
floor(): AgentJournalCursor | null {
|
||||
const state = this.deps.state()
|
||||
const sequence = latestAgentSessionContextClearSequence(state.items.values())
|
||||
return sequence > 0 ? { epoch: state.epoch, sequence } : null
|
||||
}
|
||||
|
||||
clear(
|
||||
boundary: AgentSessionProviderContextBoundary,
|
||||
receipt: JournalOperationReceipt,
|
||||
settledByOp: string
|
||||
) {
|
||||
return appendJournalContextClear({ ...this.deps, boundary, receipt, settledByOp })
|
||||
}
|
||||
|
||||
rewind(
|
||||
floor: AgentJournalCursor,
|
||||
fence: number,
|
||||
items: readonly JournalReplacementItem[],
|
||||
receipt: (cursor: AgentJournalCursor) => JournalOperationReceipt
|
||||
) {
|
||||
return rewindJournalContext({ ...this.deps, floor, fence, items, receipt })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
agentJournalSubmissionKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types'
|
||||
import type { JournalReplacementItem } from './journal-epoch-replacement'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-builders'
|
||||
import type { JournalRowWriter, JournalOperationReceipt } from './journal-row-writer'
|
||||
import { buildJournalQueueClearRow } from './journal-stop-and-resume-rows'
|
||||
import { journalQueuePauseRestatement } from './queued-message-pause'
|
||||
import type { JournalQueuedMessages } from './journal-queued-messages'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
|
||||
export async function rewindJournalContext(input: {
|
||||
state: () => JournalReducerState
|
||||
writer: JournalRowWriter
|
||||
cards: JournalQueuedMessages
|
||||
floor: AgentJournalCursor
|
||||
fence: number
|
||||
items: readonly JournalReplacementItem[]
|
||||
receipt: (cursor: AgentJournalCursor) => JournalOperationReceipt
|
||||
}): Promise<AgentJournalCursor> {
|
||||
let receipt: JournalOperationReceipt | undefined
|
||||
const rows = await input.writer.enqueueRows(
|
||||
() => {
|
||||
const state = input.state()
|
||||
if (state.epoch !== input.floor.epoch || !state.queuePauseMarks.cleared) {
|
||||
throw new Error('agent_session_rewind:stale-context')
|
||||
}
|
||||
const pause = journalQueuePauseRestatement(
|
||||
state.queuePauseMarks,
|
||||
state.latestAcceptedTurnSequence,
|
||||
input.cards.pauses()
|
||||
).cleared!
|
||||
const removed = new Map<string, boolean>()
|
||||
for (const item of state.items.values()) {
|
||||
if (item.sequence > input.floor.sequence) {
|
||||
removed.set(item.itemId, false)
|
||||
}
|
||||
}
|
||||
for (const submission of state.submissions.values()) {
|
||||
if ((submission.acceptedSequence ?? 0) > input.floor.sequence) {
|
||||
removed.set(agentJournalSubmissionKey(submission.clientMessageId), true)
|
||||
}
|
||||
}
|
||||
const revisions = new Map<string, number>()
|
||||
const plan: ((seq: number, ts: number) => JournalRow)[] = []
|
||||
for (const [itemId, retireSubmission] of removed) {
|
||||
const row = buildJournalTombstoneRow({ state, itemId, seq: 0, ts: 0, fence: input.fence })
|
||||
revisions.set(itemId, row.revision)
|
||||
plan.push((seq, ts) => ({
|
||||
...row,
|
||||
seq,
|
||||
ts,
|
||||
...(retireSubmission ? { retireSubmission: true } : {})
|
||||
}))
|
||||
}
|
||||
for (const item of input.items) {
|
||||
const itemId = agentJournalItemKey(item.identity)
|
||||
const existing = state.items.get(itemId)
|
||||
if (existing && existing.sequence <= input.floor.sequence) {
|
||||
throw new Error('agent_session_rewind:proof-mismatch')
|
||||
}
|
||||
const revision =
|
||||
Math.max(
|
||||
revisions.get(itemId) ?? 0,
|
||||
state.tombstones.get(itemId) ?? 0,
|
||||
existing?.revision ?? 0
|
||||
) + 1
|
||||
revisions.set(itemId, revision)
|
||||
plan.push((seq, ts) => ({
|
||||
...buildJournalItemRow({
|
||||
state,
|
||||
...item,
|
||||
linkage: item,
|
||||
seq,
|
||||
ts: item.observedAt ?? ts,
|
||||
fence: input.fence,
|
||||
turnScope: item.turnScope ?? { kind: 'thread' }
|
||||
}),
|
||||
revision
|
||||
}))
|
||||
}
|
||||
plan.push((seq, ts) =>
|
||||
buildJournalQueueClearRow({ state, seq, ts, fence: input.fence, clear: pause })
|
||||
)
|
||||
receipt = input.receipt({ epoch: state.epoch, sequence: state.lastSequence + plan.length })
|
||||
return plan
|
||||
},
|
||||
{
|
||||
write: (db) => receipt!.write(db),
|
||||
committed: () => receipt!.committed()
|
||||
}
|
||||
)
|
||||
const last = rows.at(-1)!
|
||||
return { epoch: last.epoch, sequence: last.seq }
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AgentJournalTurnScope
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import { isAgentSessionContextClear } from '../../../shared/agent-session-context-clear'
|
||||
|
||||
export class JournalDerivedTurnScope {
|
||||
private openTurnItemId: string | null = null
|
||||
@@ -35,6 +36,10 @@ export class JournalDerivedTurnScope {
|
||||
before: AgentJournalItemBody | undefined,
|
||||
after: AgentJournalItemBody | undefined
|
||||
): void {
|
||||
if (isAgentSessionContextClear(after)) {
|
||||
this.openTurnItemId = null
|
||||
return
|
||||
}
|
||||
const turn = readAgentJournalTurn(after)
|
||||
if (before === undefined && turn && root) {
|
||||
this.openTurnItemId = itemId
|
||||
|
||||
@@ -17,6 +17,7 @@ import { applyJournalRow, createJournalReducerState } from './journal-reducer'
|
||||
import { buildJournalItemRow, journalRowBase } from './journal-row-builders'
|
||||
import {
|
||||
buildJournalQueueReopenRow,
|
||||
buildJournalQueueClearRow,
|
||||
buildJournalQueueResumeRow,
|
||||
buildJournalStopEventRow
|
||||
} from './journal-stop-and-resume-rows'
|
||||
@@ -95,6 +96,11 @@ export function replaceJournalEpoch(input: {
|
||||
applyJournalRow(state, row)
|
||||
rows.push(row)
|
||||
}
|
||||
if (input.queuePause.cleared) {
|
||||
const row = buildJournalQueueClearRow({ ...place(), clear: input.queuePause.cleared })
|
||||
applyJournalRow(state, row)
|
||||
rows.push(row)
|
||||
}
|
||||
|
||||
const { sessionId } = input.identity
|
||||
input.database.transaction((db) => {
|
||||
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards'
|
||||
import type { JournalAttachmentClaim } from './journal-submission-hook'
|
||||
import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue'
|
||||
import { QueuedMessageNotConsumableError } from './queued-message-consume-error'
|
||||
export { QueuedMessageNotConsumableError } from './queued-message-consume-error'
|
||||
|
||||
/** Tombstones must outlive the window in which their operation id could still be admitted as new. */
|
||||
export const QUEUED_MESSAGE_REPLAY_WINDOW_MS =
|
||||
@@ -111,18 +113,13 @@ export class JournalQueuedMessages {
|
||||
return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp)
|
||||
}
|
||||
|
||||
/** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused;
|
||||
* `holdReason` carries a hold of its own over with it.
|
||||
* `requireAttachments`: a client's own draft, refused whole when an attachment it names is no
|
||||
* longer stored; the host's own writes (the carry) claim best effort.
|
||||
* `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */
|
||||
/** Attachments and the send receipt commit with the card. */
|
||||
insert(
|
||||
input: {
|
||||
messageId: string
|
||||
body: AgentJournalMessageItem
|
||||
fingerprint: string
|
||||
hostInstance: string
|
||||
carriedFrom?: string
|
||||
requireAttachments?: true
|
||||
holdReason?: QueuedMessageHoldReason
|
||||
},
|
||||
@@ -242,6 +239,21 @@ export class JournalQueuedMessages {
|
||||
)
|
||||
}
|
||||
|
||||
/** Withdraw commands with the clear's divider and receipt on the same connection. */
|
||||
withdrawInTransaction(
|
||||
db: Database.Database,
|
||||
input: { messageIds: readonly string[]; settledByOp: string }
|
||||
): void {
|
||||
if (this.deps.database().db !== db) {
|
||||
throw new AgentSessionJournalError('journal_closed', 'withdraw crossed database handles')
|
||||
}
|
||||
this.changeRevision += withdrawQueuedMessages(db, {
|
||||
...input,
|
||||
sessionId: this.deps.sessionId,
|
||||
now: this.deps.now()
|
||||
}).length
|
||||
}
|
||||
|
||||
/** One standalone draft-table transaction on the journal's queue; one that
|
||||
* changed rows bumps the revision and notifies after COMMIT, `adopted` first. */
|
||||
private transact<T>(
|
||||
@@ -369,13 +381,3 @@ export class JournalQueuedMessages {
|
||||
).then(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export class QueuedMessageNotConsumableError extends Error {
|
||||
constructor(
|
||||
readonly messageId: string,
|
||||
readonly expected: 'waiting' | 'returned'
|
||||
) {
|
||||
super(`queued message ${messageId} is no longer ${expected}`)
|
||||
this.name = 'QueuedMessageNotConsumableError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './jour
|
||||
import { applyJournalDispatchRow } from './journal-dispatch-reducer'
|
||||
import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import { projectJournalStopNote } from './journal-stop-note-projection'
|
||||
import { retireRewoundJournalSubmission } from './journal-rewind-submission-retirement'
|
||||
import { latestAgentSessionContextClearSequence } from '../../../shared/agent-session-context-clear'
|
||||
import {
|
||||
createJournalQueuePauseMarks,
|
||||
foldJournalQueuePauseMark,
|
||||
@@ -60,8 +62,7 @@ export type JournalReducerState = {
|
||||
appliedSettlementIds: Set<string>
|
||||
/** Scope for rows stored without one; rebuilt by replay, never persisted. */
|
||||
derivedTurnScope: JournalDerivedTurnScope
|
||||
/** The submission row of the latest turn the provider accepted, whoever sent it; 0 when none.
|
||||
* Kept as it folds so the queue's pause reads it in O(1). */
|
||||
/** Latest actual acceptance in this epoch, even when its message is rewound away; 0 when none. */
|
||||
latestAcceptedTurnSequence: number
|
||||
/** The latest person's Stop event and Resume, what the queue's pause is derived from. */
|
||||
queuePauseMarks: JournalQueuePauseMarks
|
||||
@@ -116,6 +117,9 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo
|
||||
}
|
||||
if (row.kind === 'tombstone') {
|
||||
removeJournalItem(state, resolveItemId(state, row.itemId), row.revision)
|
||||
if (row.retireSubmission === true) {
|
||||
retireRewoundJournalSubmission(state, row.itemId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (row.kind === 'lifecycle-batch') {
|
||||
@@ -204,6 +208,7 @@ export function journalEchoClaimant(
|
||||
return null
|
||||
}
|
||||
const fingerprint = agentSessionSendBodyFingerprint(state.sessionId, body)
|
||||
const contextSequence = latestAgentSessionContextClearSequence(state.items.values())
|
||||
// Exact payload plus queue order preserves repeated identical sends one-for-one.
|
||||
// A submission an echo may not claim is one that says the message never reached
|
||||
// the provider, so an item resembling it is somebody else's. That is `rejected`
|
||||
@@ -214,6 +219,7 @@ export function journalEchoClaimant(
|
||||
.sort((left, right) => left.submittedAt - right.submittedAt)
|
||||
.find(
|
||||
(candidate) =>
|
||||
(contextSequence === 0 || (candidate.acceptedSequence ?? 0) > contextSequence) &&
|
||||
candidate.dispatchState !== 'rejected' &&
|
||||
!isWriteFailureSubmission(candidate) &&
|
||||
candidate.payloadFingerprint === fingerprint &&
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// through the user's Retry, which rotates the client message id; Orca still
|
||||
// never puts a message back on the wire on the user's behalf.
|
||||
|
||||
import { agentSessionCurrentContextRows } from '../../../shared/agent-session-context-clear'
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import type {
|
||||
@@ -44,7 +45,11 @@ function comparableBody(body: AgentJournalMessageItem | undefined): boolean {
|
||||
}
|
||||
|
||||
function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmission[] {
|
||||
const { items, submissions } = journal.snapshot()
|
||||
const snapshot = journal.snapshot()
|
||||
const { items, submissions } = agentSessionCurrentContextRows(
|
||||
snapshot.items,
|
||||
snapshot.submissions
|
||||
)
|
||||
const bodies = new Map(items.map((item) => [item.itemId, item.body]))
|
||||
return submissions.filter((submission) => {
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
|
||||
export function retireRewoundJournalSubmission(state: JournalReducerState, itemId: string): void {
|
||||
const identity = parseAgentJournalItemKey(itemId)
|
||||
if (identity?.provider !== 'orca') {
|
||||
return
|
||||
}
|
||||
state.submissions.delete(identity.clientMessageId)
|
||||
state.receipts.delete(identity.clientMessageId)
|
||||
for (const [providerItemId, submissionItemId] of state.aliases) {
|
||||
if (submissionItemId === itemId) {
|
||||
state.aliases.delete(providerItemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,9 @@ export type JournalTombstoneRow = JournalRowBase & {
|
||||
/** On a reopen mark written after the chat stopped: where it stopped, so a send accepted since
|
||||
* lifts it. Absent: the mark's own row. */
|
||||
queueReopenSince?: number
|
||||
queueClear?: { operationId: string; messageIds: string[]; lifted?: true }
|
||||
/** A rewind retires the removed send's echo claimant as well as its visible item. */
|
||||
retireSubmission?: true
|
||||
}
|
||||
|
||||
/** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores,
|
||||
@@ -124,6 +127,7 @@ export type JournalStopOrResumeRow = JournalTombstoneRow &
|
||||
| { stopEvent: NonNullable<JournalTombstoneRow['stopEvent']> }
|
||||
| { queueResume: NonNullable<JournalTombstoneRow['queueResume']> }
|
||||
| { queueReopen: NonNullable<JournalTombstoneRow['queueReopen']> }
|
||||
| { queueClear: NonNullable<JournalTombstoneRow['queueClear']> }
|
||||
)
|
||||
|
||||
/** A Stop's event, a Resume or a reopen mark. Any value counts, so a newer build's mark never
|
||||
@@ -131,7 +135,10 @@ export type JournalStopOrResumeRow = JournalTombstoneRow &
|
||||
export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow {
|
||||
return (
|
||||
row.kind === 'tombstone' &&
|
||||
(row.stopEvent !== undefined || row.queueResume !== undefined || row.queueReopen !== undefined)
|
||||
(row.stopEvent !== undefined ||
|
||||
row.queueResume !== undefined ||
|
||||
row.queueReopen !== undefined ||
|
||||
row.queueClear !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -74,13 +74,17 @@ export class JournalRowWriter {
|
||||
/** Several rows in ONE transaction, in order, planned once the lane is this append's: none is
|
||||
* durable unless all are, so no reader ever meets some without the rest. */
|
||||
enqueueRows(
|
||||
plan: () => readonly ((seq: number, ts: number) => JournalRow)[]
|
||||
plan: () => readonly ((seq: number, ts: number) => JournalRow)[],
|
||||
receipt?: JournalOperationReceipt
|
||||
): Promise<JournalRow[]> {
|
||||
return this.deps.serialize(() => this.writeRows(plan))
|
||||
return this.deps.serialize(() => this.writeRows(plan, receipt))
|
||||
}
|
||||
|
||||
/** `enqueueRows`' write, for a caller already running at its own turn in the queue. */
|
||||
writeRows(plan: () => readonly ((seq: number, ts: number) => JournalRow)[]): JournalRow[] {
|
||||
writeRows(
|
||||
plan: () => readonly ((seq: number, ts: number) => JournalRow)[],
|
||||
receipt?: JournalOperationReceipt
|
||||
): JournalRow[] {
|
||||
assertJournalWritable(this.deps.readOnly(), this.deps.sessionId)
|
||||
const first = this.deps.nextSequence()
|
||||
const ts = this.deps.now()
|
||||
@@ -97,11 +101,13 @@ export class JournalRowWriter {
|
||||
insertJournalRow(db, this.deps.sessionId, row)
|
||||
this.runBookkeeping(db, row)
|
||||
}
|
||||
receipt?.write(db)
|
||||
})
|
||||
} catch (error) {
|
||||
this.deps.rolledBack?.()
|
||||
throw error
|
||||
}
|
||||
receipt?.committed()
|
||||
for (const row of rows) {
|
||||
this.deps.commit(row)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@ const JOURNAL_QUEUE_REOPEN_ITEM_ID = agentJournalItemKey({
|
||||
|
||||
type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number }
|
||||
|
||||
export function buildJournalQueueClearRow(
|
||||
input: RowPlace & { clear: NonNullable<JournalTombstoneRow['queueClear']> }
|
||||
): JournalTombstoneRow {
|
||||
return {
|
||||
kind: 'tombstone',
|
||||
itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-context-clear' }),
|
||||
revision: 1,
|
||||
queueClear: input.clear,
|
||||
...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildJournalStopEventRow(
|
||||
input: RowPlace & { event: JournalStopEvent }
|
||||
): JournalTombstoneRow {
|
||||
|
||||
@@ -28,6 +28,7 @@ export function stopIsAPersons(reason: JournalStopEvent['reason']): boolean {
|
||||
case 'user-stop':
|
||||
case 'user-close':
|
||||
return true
|
||||
case 'context-clear':
|
||||
case 'host-stop':
|
||||
case 'evict':
|
||||
return false
|
||||
|
||||
@@ -74,6 +74,7 @@ import type { JournalItemAppender } from './journal-item-appender'
|
||||
import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender'
|
||||
import type { JournalStepWriter } from './journal-step-writer'
|
||||
import type { JournalStopMarks } from './journal-stop-marks'
|
||||
import { JournalContextController } from './journal-context-controller'
|
||||
|
||||
export { AgentSessionJournalError } from './journal-write-guards'
|
||||
|
||||
@@ -98,6 +99,7 @@ export class AgentSessionJournal {
|
||||
/** Draft rows queued while the agent works; never reducer input or owed work. */
|
||||
readonly queuedMessages: JournalQueuedMessages
|
||||
readonly stopMarks: JournalStopMarks
|
||||
readonly context: JournalContextController
|
||||
|
||||
constructor(options: AgentSessionJournalOptions) {
|
||||
this.identity = options.identity
|
||||
@@ -138,6 +140,11 @@ export class AgentSessionJournal {
|
||||
this.queuedMessages = collaborators.queuedMessages
|
||||
this.stopMarks = collaborators.stopMarks
|
||||
this.restore = collaborators.restore
|
||||
this.context = new JournalContextController({
|
||||
state: () => this.state,
|
||||
writer: this.rowWriter,
|
||||
cards: this.queuedMessages
|
||||
})
|
||||
}
|
||||
|
||||
get epoch(): string {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export class QueuedMessageNotConsumableError extends Error {
|
||||
constructor(
|
||||
readonly messageId: string,
|
||||
readonly expected: 'waiting' | 'returned'
|
||||
) {
|
||||
super(`queued message ${messageId} is no longer ${expected}`)
|
||||
this.name = 'QueuedMessageNotConsumableError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createJournalQueuePauseMarks,
|
||||
deriveQueuePauses,
|
||||
journalQueuePauseRestatement,
|
||||
queuePauseHolding,
|
||||
nextSendableQueuedCard
|
||||
} from './queued-message-pause'
|
||||
|
||||
const card = (messageId: string) => ({
|
||||
messageId,
|
||||
state: 'waiting',
|
||||
holdReason: null,
|
||||
queuedAt: { epoch: 'epoch', sequence: 2 }
|
||||
})
|
||||
const marks = () => ({
|
||||
...createJournalQueuePauseMarks(),
|
||||
cleared: { sequence: 5, operationId: 'clear', messageIds: ['before-one', 'before-two'] }
|
||||
})
|
||||
const pauses = (cards = [card('before-one'), card('before-two')], m = marks(), accepted = 0) =>
|
||||
deriveQueuePauses({
|
||||
epoch: 'epoch',
|
||||
marks: m,
|
||||
latestAcceptedTurnSequence: accepted,
|
||||
cards,
|
||||
reopenFloor: null
|
||||
})
|
||||
|
||||
describe('exact waiting-card membership after context clear', () => {
|
||||
it('holds only recorded waiting IDs and intersects them with cards still waiting', () => {
|
||||
const after = card('after')
|
||||
const current = [card('before-two'), after]
|
||||
const held = pauses(current)
|
||||
expect(held).toEqual([
|
||||
{ reason: 'cleared', since: { epoch: 'epoch', sequence: 5 }, messageIds: ['before-two'] }
|
||||
])
|
||||
expect(queuePauseHolding(held, after)).toBeUndefined()
|
||||
expect(nextSendableQueuedCard(held, current)).toBeNull()
|
||||
expect(pauses([after])).toEqual([])
|
||||
expect(nextSendableQueuedCard(pauses([after]), [after])).toEqual(after)
|
||||
})
|
||||
|
||||
it('a removed or returned member cannot strand the clear pause', () => {
|
||||
const returned = { ...card('before-one'), state: 'returned' }
|
||||
expect(
|
||||
deriveQueuePauses({
|
||||
epoch: 'epoch',
|
||||
marks: marks(),
|
||||
latestAcceptedTurnSequence: 0,
|
||||
cards: [returned],
|
||||
reopenFloor: null
|
||||
})
|
||||
).toEqual([])
|
||||
expect(pauses([])).toEqual([])
|
||||
})
|
||||
|
||||
it('ends on a new accepted turn or Resume, while older acceptance does not lift it', () => {
|
||||
expect(pauses(undefined, undefined, 4)).toHaveLength(1)
|
||||
expect(pauses(undefined, undefined, 6)).toEqual([])
|
||||
expect(pauses(undefined, { ...marks(), resumedSequence: 6 })).toEqual([])
|
||||
})
|
||||
|
||||
it('restates an explicit lift when rewind retires the accepted turn', () => {
|
||||
const prior = marks()
|
||||
const cards = [card('before-one'), card('before-two')]
|
||||
const restated = journalQueuePauseRestatement(prior, 6, pauses(cards, prior, 6)).cleared!
|
||||
expect(restated.lifted).toBe(true)
|
||||
expect(pauses(cards, { ...prior, cleared: { ...restated, sequence: 10 } }, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('restates only still-held IDs when rewind changes the journal suffix', () => {
|
||||
const prior = marks()
|
||||
const current = [card('before-two')]
|
||||
expect(journalQueuePauseRestatement(prior, 0, pauses(current, prior)).cleared).toEqual({
|
||||
operationId: 'clear',
|
||||
messageIds: ['before-two']
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, an accepted
|
||||
// turn is a row, and a /clear's carried card names its source. Nothing is stored beside
|
||||
// them, so nothing has to retire.
|
||||
// turn is a row, and /clear records the exact waiting cards. No separate pause state is stored.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -57,16 +56,23 @@ function open(): Promise<AgentSessionJournal> {
|
||||
})
|
||||
}
|
||||
|
||||
function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom?: string) {
|
||||
function queueDraft(journal: AgentSessionJournal, messageId: string) {
|
||||
return journal.queuedMessages.insert({
|
||||
messageId,
|
||||
body: message(messageId),
|
||||
fingerprint: `fp-${messageId}`,
|
||||
hostInstance: HOST,
|
||||
...(carriedFrom ? { carriedFrom } : {})
|
||||
hostInstance: HOST
|
||||
})
|
||||
}
|
||||
|
||||
function clearContext(journal: AgentSessionJournal) {
|
||||
return journal.context.clear(
|
||||
{ operationId: `clear-${++clock}`, afterFence: 0, clearedAt: clock },
|
||||
{ write: () => {}, committed: () => {} },
|
||||
`caller-clear-${clock}`
|
||||
)
|
||||
}
|
||||
|
||||
/** A turn sent, then accepted by the provider. */
|
||||
async function turn(journal: AgentSessionJournal, id: string, accept = true): Promise<void> {
|
||||
await journal.appendSubmission({
|
||||
@@ -202,9 +208,10 @@ describe("the queue's pause, derived from the journal", () => {
|
||||
expect(pauseTables()).toBe(0)
|
||||
})
|
||||
|
||||
it("a card /clear carried in pauses the replacement 'cleared' until a Resume there", async () => {
|
||||
it('a card kept by /clear stays paused until Resume in the same journal', async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'carried', 'source-session')
|
||||
await queueDraft(journal, 'carried')
|
||||
await clearContext(journal)
|
||||
expect(reason(journal)).toBe('cleared')
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
@@ -214,9 +221,10 @@ describe("the queue's pause, derived from the journal", () => {
|
||||
expect(pauseTables()).toBe(0)
|
||||
})
|
||||
|
||||
it("any accepted turn on the replacement lifts 'cleared', a launch prompt Orca sent included", async () => {
|
||||
it('any accepted turn after /clear lifts its pause, a launch prompt Orca sent included', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'carried', 'source-session')
|
||||
await queueDraft(journal, 'carried')
|
||||
await clearContext(journal)
|
||||
await turn(journal, 'launch', false)
|
||||
expect(reason(journal)).toBe('cleared')
|
||||
await acceptTurn(journal, 'launch')
|
||||
@@ -376,7 +384,8 @@ describe("the queue's pause, derived from the journal", () => {
|
||||
['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)]
|
||||
])('a /clear pause %s already lifted stays lifted across a rewind', async (_name, lift) => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'carried', 'source-session')
|
||||
await queueDraft(journal, 'carried')
|
||||
await clearContext(journal)
|
||||
await lift(journal)
|
||||
expect(reason(journal)).toBeNull()
|
||||
await journal.replaceEpochItems('handle_forked', 0, [])
|
||||
@@ -385,7 +394,8 @@ describe("the queue's pause, derived from the journal", () => {
|
||||
|
||||
it('a lifted /clear pause and a later Stop are both restated, the Stop still in force', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'carried', 'source-session')
|
||||
await queueDraft(journal, 'carried')
|
||||
await clearContext(journal)
|
||||
await turn(journal, 'typed')
|
||||
await userStop(journal)
|
||||
await journal.replaceEpochItems('handle_forked', 0, [])
|
||||
@@ -466,10 +476,11 @@ describe('which cards a pause holds', () => {
|
||||
expect(journal.queuedMessages.get('newer')?.state).toBe('waiting')
|
||||
})
|
||||
|
||||
it("'cleared' holds the carried cards, not one typed after them", async () => {
|
||||
it("'cleared' holds the cards already queued, not one typed after it", async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'carried-1', 'source-session')
|
||||
await queueDraft(journal, 'carried-2', 'source-session')
|
||||
await queueDraft(journal, 'carried-1')
|
||||
await queueDraft(journal, 'carried-2')
|
||||
await clearContext(journal)
|
||||
await queueDraft(journal, 'typed-here')
|
||||
expect(held(journal)).toEqual([
|
||||
['carried-1', true],
|
||||
@@ -638,19 +649,22 @@ describe('which cards the pauses in force hold', () => {
|
||||
|
||||
function card(messageId: string, queuedAfter: number, fields: Partial<Card> = {}): Card {
|
||||
const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 }
|
||||
const base = { state: 'waiting', holdReason: null, carriedFrom: null }
|
||||
const base = { state: 'waiting', holdReason: null }
|
||||
return { messageId, ...base, queuedAt, ...fields }
|
||||
}
|
||||
|
||||
/** A Stop at sequence 5 unless `stopped` is 0; this handle opened at `opened` and could not mark
|
||||
* it (0: nothing to mark); no turn, Resume or reopen mark since. */
|
||||
function pausesOver(cards: readonly Card[], stopped = 5, opened = 0) {
|
||||
function pausesOver(cards: readonly Card[], stopped = 5, opened = 0, clearedIds?: string[]) {
|
||||
return deriveQueuePauses({
|
||||
epoch: 'epoch-1',
|
||||
marks: {
|
||||
latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null,
|
||||
resumedSequence: 0,
|
||||
reopenedSequence: 0
|
||||
reopenedSequence: 0,
|
||||
...(clearedIds
|
||||
? { cleared: { sequence: 4, operationId: 'clear', messageIds: clearedIds } }
|
||||
: {})
|
||||
},
|
||||
latestAcceptedTurnSequence: 0,
|
||||
cards,
|
||||
@@ -717,15 +731,15 @@ describe('which cards the pauses in force hold', () => {
|
||||
})
|
||||
|
||||
it("a /clear's pause that holds nothing never hides a reopen's", () => {
|
||||
const cards = [
|
||||
card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }),
|
||||
card('typed', 2)
|
||||
]
|
||||
expect(pausesOver(cards, 0, 4).map((pause) => pause.reason)).toEqual(['cleared', 'restarted'])
|
||||
expect(holding(cards, 0, 4)).toEqual([
|
||||
['carried', null],
|
||||
const cards = [card('cleared', 1, { holdReason: 'send_failed' }), card('typed', 2)]
|
||||
const pauses = pausesOver(cards, 0, 4, ['cleared'])
|
||||
expect(pauses.map((pause) => pause.reason)).toEqual(['cleared', 'restarted'])
|
||||
expect(
|
||||
cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null])
|
||||
).toEqual([
|
||||
['cleared', null],
|
||||
['typed', 'restarted']
|
||||
])
|
||||
expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull()
|
||||
expect(nextSendableQueuedCard(pauses, cards)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
// - 'stopped': the latest Stop event is a person's (reason `user-stop`), with no later Resume row
|
||||
// and no turn sent after it and accepted. A later Stop of any reason supersedes it; only a
|
||||
// person's pauses.
|
||||
// - 'cleared': a card /clear carried into this conversation waits, and no turn or Resume has
|
||||
// happened here since.
|
||||
// - 'cleared': a card named by the latest clear mark waits, with no later accepted turn or Resume.
|
||||
// - 'restarted': a card queued before this conversation last opened waits — Orca quit or
|
||||
// crashed, or the chat closed, while it waited — and no turn or Resume has happened since the
|
||||
// open. The open marks itself with a row when it finds waiting cards (`queueReopen`), so a card
|
||||
@@ -38,6 +37,7 @@ export type JournalStopFailedOn = { turnId: string } | { openedAfter: number }
|
||||
/** The latest Stop event, whatever its reason, the latest Resume row and the latest reopen mark,
|
||||
* folded by the reducer. */
|
||||
export type JournalQueuePauseMarks = {
|
||||
cleared?: { sequence: number; operationId: string; messageIds: readonly string[]; lifted?: true }
|
||||
latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null
|
||||
/** 0 when none. */
|
||||
resumedSequence: number
|
||||
@@ -47,15 +47,15 @@ export type JournalQueuePauseMarks = {
|
||||
|
||||
export type DerivedQueuePause = {
|
||||
reason: QueuePauseReason
|
||||
/** Where a Stop's or a reopen's pause began: a card queued at or after it is newer. Null for
|
||||
* /clear's, which holds the cards it carried. */
|
||||
/** Where the pause began; clear holds its exact recorded message IDs. */
|
||||
since: AgentJournalCursor | null
|
||||
messageIds?: readonly string[]
|
||||
}
|
||||
|
||||
type QueueCard = {
|
||||
messageId?: string
|
||||
state: string
|
||||
holdReason: string | null
|
||||
carriedFrom: string | null
|
||||
queuedAt: AgentJournalCursor | null
|
||||
}
|
||||
|
||||
@@ -89,9 +89,27 @@ export function foldJournalQueuePauseMark(
|
||||
const since = row.queueReopenSince
|
||||
const start = typeof since === 'number' && since > 0 && since <= row.seq ? since : row.seq
|
||||
marks.reopenedSequence = Math.max(marks.reopenedSequence, start)
|
||||
} else if (isReadableQueueClear(row.queueClear)) {
|
||||
marks.cleared = { ...row.queueClear, sequence: row.seq }
|
||||
}
|
||||
}
|
||||
|
||||
function isReadableQueueClear(
|
||||
value: unknown
|
||||
): value is NonNullable<JournalTombstoneRow['queueClear']> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'operationId' in value &&
|
||||
typeof value.operationId === 'string' &&
|
||||
value.operationId.length > 0 &&
|
||||
'messageIds' in value &&
|
||||
Array.isArray(value.messageIds) &&
|
||||
value.messageIds.every((id: unknown) => typeof id === 'string' && id.length > 0) &&
|
||||
(!('lifted' in value) || value.lifted === true)
|
||||
)
|
||||
}
|
||||
|
||||
/** A person's Stop still pauses: it is the latest Stop, and no turn accepted since, and no
|
||||
* Resume, ended it. A later Stop for any other reason ends it without pausing itself. */
|
||||
export function journalQueueStopHolds(
|
||||
@@ -119,6 +137,7 @@ export type JournalQueuePauseRestatement = {
|
||||
lifted: boolean
|
||||
liveStop: JournalStopEvent | null
|
||||
reopened: boolean
|
||||
cleared?: { operationId: string; messageIds: string[]; lifted?: true }
|
||||
}
|
||||
|
||||
export function journalQueuePauseRestatement(
|
||||
@@ -129,7 +148,19 @@ export function journalQueuePauseRestatement(
|
||||
return {
|
||||
lifted: latestAcceptedTurnSequence > 0 || marks.resumedSequence > 0,
|
||||
liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null,
|
||||
reopened: pauses.some((pause) => pause.reason === 'restarted')
|
||||
reopened: pauses.some((pause) => pause.reason === 'restarted'),
|
||||
...(marks.cleared
|
||||
? {
|
||||
cleared: {
|
||||
operationId: marks.cleared.operationId,
|
||||
messageIds: [...(pauses.find((pause) => pause.reason === 'cleared')?.messageIds ?? [])],
|
||||
...(marks.cleared.lifted ||
|
||||
Math.max(latestAcceptedTurnSequence, marks.resumedSequence) >= marks.cleared.sequence
|
||||
? { lifted: true as const }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,9 +181,19 @@ export function deriveQueuePauses(input: {
|
||||
pauses.push({ reason: 'stopped', since: { epoch, sequence: stop.sequence } })
|
||||
}
|
||||
const waiting = input.cards.filter((card) => card.state === 'waiting')
|
||||
const carried = waiting.filter((card) => card.carriedFrom !== null)
|
||||
if (carried.length > 0 && latestAcceptedTurnSequence === 0 && marks.resumedSequence === 0) {
|
||||
pauses.push({ reason: 'cleared', since: null })
|
||||
const cleared = marks.cleared
|
||||
if (
|
||||
cleared &&
|
||||
!cleared.lifted &&
|
||||
cleared.sequence > Math.max(latestAcceptedTurnSequence, marks.resumedSequence)
|
||||
) {
|
||||
const membership = new Set(cleared.messageIds)
|
||||
const messageIds = waiting.flatMap((card) =>
|
||||
card.messageId && membership.has(card.messageId) ? [card.messageId] : []
|
||||
)
|
||||
if (messageIds.length > 0) {
|
||||
pauses.push({ reason: 'cleared', since: { epoch, sequence: cleared.sequence }, messageIds })
|
||||
}
|
||||
}
|
||||
const reopened = reopenPause(input)
|
||||
if (
|
||||
@@ -186,12 +227,10 @@ function reopenPause(input: {
|
||||
return { reason: 'restarted', since: { epoch, sequence } }
|
||||
}
|
||||
|
||||
/** Queued before the pause began: for /clear, a card it carried. For a Stop or a reopen, a card
|
||||
* queued before its row; one from another epoch (before a rewind) or from a build that recorded
|
||||
* no position counts as before. A withdrawn steer keeps its position, so is held. */
|
||||
/** Clear holds recorded IDs; other pauses compare queued positions within the journal epoch. */
|
||||
function queuedBefore(pause: DerivedQueuePause, card: QueueCard): boolean {
|
||||
if (pause.reason === 'cleared') {
|
||||
return card.carriedFrom !== null
|
||||
return pause.messageIds?.includes(card.messageId ?? '') ?? false
|
||||
}
|
||||
const { since } = pause
|
||||
return (
|
||||
|
||||
@@ -56,8 +56,7 @@ export type QueuedMessageRow = {
|
||||
* card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is
|
||||
* the submission's `queuedMessageId`. */
|
||||
consumedAs: string | null
|
||||
/** The conversation /clear carried this card from; null for a card written here. What the
|
||||
* replacement's 'cleared' pause is derived from. */
|
||||
/** Inert historical column; current inserts write null. */
|
||||
carriedFrom: string | null
|
||||
/** Where the journal stood when it was queued: a Stop's pause holds only cards queued before
|
||||
* it. Null on rows from builds before it was recorded, which read as queued before any Stop. */
|
||||
@@ -75,7 +74,6 @@ export function insertQueuedMessage(
|
||||
body: AgentJournalMessageItem
|
||||
fingerprint: string
|
||||
hostInstance: string
|
||||
carriedFrom?: string
|
||||
queuedAt: AgentJournalCursor
|
||||
now: number
|
||||
/** Absent: after every other card. */
|
||||
@@ -100,7 +98,7 @@ export function insertQueuedMessage(
|
||||
input.now,
|
||||
input.hostInstance,
|
||||
input.holdReason ?? null,
|
||||
input.carriedFrom ?? null,
|
||||
null,
|
||||
input.queuedAt.epoch,
|
||||
input.queuedAt.sequence
|
||||
)
|
||||
@@ -119,7 +117,7 @@ export function insertQueuedMessage(
|
||||
settledAt: null,
|
||||
settledByOp: null,
|
||||
consumedAs: null,
|
||||
carriedFrom: input.carriedFrom ?? null,
|
||||
carriedFrom: null,
|
||||
queuedAt: input.queuedAt
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -152,7 +152,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("a Claude chat whose Claude isn't running shows the `/` surface from its folders", () => {
|
||||
it('after /clear, before the new chat has started', async () => {
|
||||
it('after /clear, before the fresh agent context starts', async () => {
|
||||
const cleared = await host.conversationCommand(caller, {
|
||||
command: 'clear',
|
||||
envelope: {
|
||||
@@ -166,7 +166,8 @@ describe("a Claude chat whose Claude isn't running shows the `/` surface from it
|
||||
})
|
||||
}
|
||||
})
|
||||
const replacement = cleared.ok ? cleared.value.replacementSessionId! : ''
|
||||
expect(cleared).toMatchObject({ ok: true })
|
||||
const replacement = SESSION
|
||||
const menu = await menuOf(replacement)
|
||||
expect(namesOf(menu, 'skill')).toEqual(['review-pr'])
|
||||
// Everything the menu offered before a list existed, then what the folders add.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { activeProviderContext } from '../../../shared/agent-session-provider-context'
|
||||
// Attach: reserve the session record, then open its journal.
|
||||
//
|
||||
// `create` and `ensure` are the same transition with a different starting
|
||||
@@ -40,7 +41,6 @@ import {
|
||||
computeAgentSessionPayloadFingerprint
|
||||
} from '../../../shared/agent-session-mutation-envelope'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation'
|
||||
import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler'
|
||||
@@ -163,10 +163,13 @@ export function journalIdentityFor(
|
||||
record: AgentSessionRecord,
|
||||
params: AgentSessionAttachParams
|
||||
): AgentSessionJournalIdentity {
|
||||
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
|
||||
const active = activeProviderContext(record)
|
||||
const head = active.head
|
||||
const providerHandle =
|
||||
head?.handle ??
|
||||
(params.providerHandle ? agentSessionProviderHandleFromWire(params.providerHandle) : null)
|
||||
(!active.pendingClear && params.providerHandle
|
||||
? agentSessionProviderHandleFromWire(params.providerHandle)
|
||||
: null)
|
||||
return {
|
||||
sessionId: record.sessionId,
|
||||
workspaceId: params.location.workspaceId,
|
||||
|
||||
+3
-4
@@ -86,7 +86,7 @@ describe('attachments at send admission', () => {
|
||||
expect(await rig.drafts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('claims a stored upload, and the /clear carry claims it for the replacement chat', async () => {
|
||||
it('keeps a stored upload claimed by the same conversation after clear', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const path = await storeUpload('notes.txt')
|
||||
expect(await clientSend(textWith(path), 'queue-if-active')).toMatchObject({
|
||||
@@ -101,9 +101,8 @@ describe('attachments at send admission', () => {
|
||||
envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()),
|
||||
...fields
|
||||
})
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
expect(replacementId).toBeTruthy()
|
||||
expect(claimedSessions()).toEqual([SESSION, replacementId].sort())
|
||||
expect(cleared).toMatchObject({ ok: true })
|
||||
expect(claimedSessions()).toEqual([SESSION])
|
||||
})
|
||||
|
||||
it('sends text that only mentions another store path, claiming nothing', async () => {
|
||||
|
||||
@@ -139,7 +139,8 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize<
|
||||
expected &&
|
||||
exit.startupUnanswered &&
|
||||
close?.cause !== 'host-stop' &&
|
||||
close?.cause !== 'user-stop'
|
||||
close?.cause !== 'user-stop' &&
|
||||
close?.cause !== 'context-clear'
|
||||
? close?.quit
|
||||
? ('hostRestarted' as const)
|
||||
: ('chatClosed' as const)
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
eventually,
|
||||
QUEUED_RIG_CALLER as CALLER,
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
|
||||
let rig: QueuedMessageTestRig
|
||||
beforeEach(async () => {
|
||||
rig = await createQueuedMessageTestRig()
|
||||
})
|
||||
afterEach(() => rig.dispose())
|
||||
|
||||
function command(name: 'clear' | 'compact') {
|
||||
const fields = {
|
||||
command: name,
|
||||
...(name === 'compact' ? { delivery: 'queue-if-active' as const } : {})
|
||||
}
|
||||
return rig.host.conversationCommand(CALLER, {
|
||||
envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()),
|
||||
...fields
|
||||
})
|
||||
}
|
||||
|
||||
it('clear refuses Working before releasing or cancelling its provider', async () => {
|
||||
await rig.workingSend()
|
||||
expect(await command('clear')).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { details: { reason: 'messagesUnsettled' } }
|
||||
})
|
||||
expect(rig.closeSession).not.toHaveBeenCalled()
|
||||
expect(rig.cancelTurn).not.toHaveBeenCalled()
|
||||
expect(rig.store.getRecord(SESSION)?.providerContextBoundary).toBeUndefined()
|
||||
})
|
||||
|
||||
it('compact arriving during clear waits for its lane and is refused without a card or send', async () => {
|
||||
const working = await rig.workingSend()
|
||||
await rig.settleAccepted(working, 'answer')
|
||||
let release = () => {}
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
rig.closeSession.mockImplementationOnce(async () => {
|
||||
await held
|
||||
return true
|
||||
})
|
||||
const clearing = command('clear')
|
||||
await eventually(() => expect(rig.closeSession).toHaveBeenCalledOnce())
|
||||
let answered = false
|
||||
const compacting = command('compact').then((result) => {
|
||||
answered = true
|
||||
return result
|
||||
})
|
||||
try {
|
||||
await Promise.resolve()
|
||||
expect(answered).toBe(false)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
expect(await clearing).toMatchObject({ ok: true })
|
||||
expect(await compacting).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { details: { reason: 'conversationCommandInFlight' } }
|
||||
})
|
||||
expect(await rig.drafts()).toEqual([])
|
||||
expect(rig.compact).not.toHaveBeenCalled()
|
||||
expect(rig.dispatch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clear removes command cards from history and connected and reconnecting client snapshots', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const first = rig.send('first', 'queue-if-active')
|
||||
await first.result
|
||||
const compact = await command('compact')
|
||||
if (!compact.ok || !compact.value.queued) {
|
||||
throw new Error('expected a command card')
|
||||
}
|
||||
const last = rig.send('last', 'queue-if-active')
|
||||
await last.result
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'answer')
|
||||
const connected: AgentSessionSubscribeEvent[] = []
|
||||
await rig.host.subscribe({
|
||||
id: 'connected',
|
||||
sessionId: SESSION,
|
||||
emit: (event) => connected.push(event)
|
||||
})
|
||||
expect(await command('clear')).toMatchObject({ ok: true })
|
||||
const reconnecting: AgentSessionSubscribeEvent[] = []
|
||||
await rig.host.subscribe({
|
||||
id: 'reconnecting',
|
||||
sessionId: SESSION,
|
||||
emit: (event) => reconnecting.push(event)
|
||||
})
|
||||
const expected = [first.id, last.id].map((messageId) => ({ messageId, state: 'waiting' }))
|
||||
expect(await rig.drafts()).toEqual(expected)
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
expect(
|
||||
rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal.queuedMessages.pauses()
|
||||
).toContainEqual(expect.objectContaining({ reason: 'cleared', messageIds: [first.id, last.id] }))
|
||||
for (const events of [connected, reconnecting]) {
|
||||
await eventually(() => {
|
||||
const cards = events
|
||||
.flatMap((event) =>
|
||||
event.type !== 'end' && event.queuedMessages ? [event.queuedMessages] : []
|
||||
)
|
||||
.at(-1)
|
||||
expect(cards?.map(({ messageId, state }) => ({ messageId, state }))).toEqual(expected)
|
||||
})
|
||||
}
|
||||
expect(await rig.handoff(compact.value.queued.messageId)).toBeUndefined()
|
||||
expect(rig.compact).not.toHaveBeenCalled()
|
||||
expect(rig.dispatch).toHaveBeenCalledOnce()
|
||||
})
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { activeProviderContext } from '../../../shared/agent-session-provider-context'
|
||||
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { recoverStructuredRewind } from './structured-rewind-recovery'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
eventually,
|
||||
QUEUED_RIG_CALLER as CALLER,
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { structuredQueuePauses } from './structured-agent-session-queued-pause'
|
||||
import { queuePauseHolding } from '../agent-session-journal/queued-message-pause'
|
||||
|
||||
let rig: QueuedMessageTestRig
|
||||
const rewind = vi.fn<NonNullable<StructuredAgentSessionAdapter['rewind']>>()
|
||||
|
||||
beforeEach(async () => {
|
||||
rewind.mockReset().mockResolvedValue({ ok: true })
|
||||
rig = await createQueuedMessageTestRig({ restartable: true, rewind })
|
||||
})
|
||||
afterEach(() => rig.dispose())
|
||||
|
||||
const journal = () => rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal
|
||||
const db = () => openTestJournalHostDatabase(rig.root).db
|
||||
const rawPrefix = (sequence: number) =>
|
||||
db()
|
||||
.prepare(
|
||||
'SELECT seq, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq <= ? ORDER BY seq'
|
||||
)
|
||||
.all(SESSION, journal().cursor().epoch, sequence)
|
||||
|
||||
async function clear() {
|
||||
const fields = { command: 'clear' as const }
|
||||
const result = await rig.host.conversationCommand(CALLER, {
|
||||
...fields,
|
||||
envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId())
|
||||
})
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
}
|
||||
|
||||
async function send(text: string, turn: string) {
|
||||
const sent = rig.send(text)
|
||||
expect(await sent.result).toMatchObject({ ok: true })
|
||||
await eventually(async () => expect((await rig.submission(sent.id))?.handedOverAt).toBeDefined())
|
||||
await rig.settleAccepted(sent.id, turn)
|
||||
return agentJournalSubmissionKey(sent.id)
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
const old = await send('before clear', 'old')
|
||||
await clear()
|
||||
const floor = journal().context.floor()!
|
||||
const prefix = journal()
|
||||
.snapshot()
|
||||
.items.filter((item) => item.sequence <= floor.sequence)
|
||||
const raw = rawPrefix(floor.sequence)
|
||||
const kept = await send('kept after clear', 'kept')
|
||||
const target = await send('rewound after clear', 'target')
|
||||
const fields = { itemId: target, expectedEpoch: journal().cursor().epoch }
|
||||
const params = {
|
||||
...fields,
|
||||
envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId())
|
||||
}
|
||||
return { old, kept, target, floor, prefix, raw, params }
|
||||
}
|
||||
|
||||
async function returnedCardAndWaitingCard() {
|
||||
const working = await rig.workingSend()
|
||||
const returned = rig.send('returned card', 'queue-if-active')
|
||||
const waiting = rig.send('waiting card', 'queue-if-active')
|
||||
expect(await returned.result).toMatchObject({ ok: true })
|
||||
expect(await waiting.result).toMatchObject({ ok: true })
|
||||
await rig.settleAccepted(working, 'working')
|
||||
await eventually(async () => expect((await rig.handoff(returned.id))?.handedOverAt).toBeDefined())
|
||||
await rig.stop()
|
||||
await rig.settleRejected(await rig.handoffId(returned.id), 'provider refused the card')
|
||||
await eventually(async () =>
|
||||
expect(await rig.drafts()).toEqual([
|
||||
{ messageId: returned.id, state: 'returned' },
|
||||
{ messageId: waiting.id, state: 'waiting' }
|
||||
])
|
||||
)
|
||||
return { returned: returned.id, waiting: waiting.id }
|
||||
}
|
||||
|
||||
async function replayedJournal() {
|
||||
const reopened = new AgentSessionJournal({
|
||||
database: openTestJournalHostDatabase(rig.root),
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: activeProviderContext(rig.store.getRecord(SESSION)!).head!.handle
|
||||
}
|
||||
})
|
||||
await reopened.open()
|
||||
return reopened
|
||||
}
|
||||
|
||||
describe('rewind behind a clear boundary', () => {
|
||||
it.each(['stop', 'reopen'] as const)(
|
||||
'keeps a lifted %s pause lifted after suffix rewind and journal replay',
|
||||
async (pause) => {
|
||||
await clear()
|
||||
const { returned, waiting } = await returnedCardAndWaitingCard()
|
||||
if (pause === 'reopen') {
|
||||
await journal().appendQueueReopen(rig.store.getRecord(SESSION)!.lease.runtimeFence)
|
||||
}
|
||||
const target = await send('lifts the pauses', 'lift')
|
||||
expect(structuredQueuePauses(journal())).toEqual([])
|
||||
const fields = { itemId: target, expectedEpoch: journal().cursor().epoch }
|
||||
expect(
|
||||
await rig.host.rewind(CALLER, {
|
||||
...fields,
|
||||
envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId())
|
||||
})
|
||||
).toMatchObject({ ok: true })
|
||||
expect(structuredQueuePauses(journal())).toEqual([])
|
||||
expect((await replayedJournal()).queuedMessages.pauses()).toEqual([])
|
||||
expect(await rig.deleteQueued(returned)).toMatchObject({ ok: true })
|
||||
await eventually(async () => expect(await rig.handoff(waiting)).toBeDefined())
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a later Stop active without holding cards queued after that Stop', async () => {
|
||||
await clear()
|
||||
const { waiting } = await returnedCardAndWaitingCard()
|
||||
await send('lifts the earlier stop', 'lift')
|
||||
const later = await rig.workingSend()
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(later, 'stopped-later')
|
||||
const target = agentJournalSubmissionKey(later)
|
||||
const newer = await journal().queuedMessages.insert({
|
||||
messageId: 'queued-after-stop',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'newer card' }] },
|
||||
fingerprint: 'newer-card',
|
||||
hostInstance: 'test'
|
||||
})
|
||||
const originalPause = structuredQueuePauses(journal()).find(
|
||||
(entry) => entry.reason === 'stopped'
|
||||
)
|
||||
expect(originalPause).toBeDefined()
|
||||
const fields = { itemId: target, expectedEpoch: journal().cursor().epoch }
|
||||
expect(
|
||||
await rig.host.rewind(CALLER, {
|
||||
...fields,
|
||||
envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId())
|
||||
})
|
||||
).toMatchObject({ ok: true })
|
||||
expect(structuredQueuePauses(journal()).find((entry) => entry.reason === 'stopped')).toEqual(
|
||||
originalPause
|
||||
)
|
||||
expect(journal().queuedMessages.get(newer.messageId)?.queuedAt).toEqual(newer.queuedAt)
|
||||
expect(queuePauseHolding(structuredQueuePauses(journal()), newer)).toBeUndefined()
|
||||
const replayedPauses = (await replayedJournal()).queuedMessages.pauses()
|
||||
expect(replayedPauses.find((entry) => entry.reason === 'stopped')).toEqual(originalPause)
|
||||
expect(queuePauseHolding(replayedPauses, newer)).toBeUndefined()
|
||||
expect(queuePauseHolding(replayedPauses, journal().queuedMessages.get(waiting)!)?.reason).toBe(
|
||||
'stopped'
|
||||
)
|
||||
expect(await rig.handoff(waiting)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects prefix and divider targets before acquiring or calling the provider', async () => {
|
||||
const old = await send('earlier', 'old')
|
||||
await clear()
|
||||
const marker = journal().snapshot().items.at(-1)!.itemId
|
||||
const starts = rig.starts.mock.calls.length
|
||||
for (const itemId of [old, marker]) {
|
||||
const fields = { itemId, expectedEpoch: journal().cursor().epoch }
|
||||
expect(
|
||||
await rig.host.rewind(CALLER, {
|
||||
...fields,
|
||||
envelope: rig.envelope(fields, 'agentSession.rewind', hostTestOperationId())
|
||||
})
|
||||
).toMatchObject({ ok: false, refusal: { rewindReason: 'invalid-target' } })
|
||||
}
|
||||
expect(rig.starts).toHaveBeenCalledTimes(starts)
|
||||
expect(rewind).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rewrites only the suffix, retires its aliases, and keeps prefix bytes and epoch', async () => {
|
||||
const { old, target, floor, prefix, raw, params } = await seed()
|
||||
rewind.mockImplementationOnce(async () => {
|
||||
const prepared = rig.store.getRecord(SESSION)!.rewind!
|
||||
expect(prepared.contextClearOperationId).toBe(
|
||||
rig.store.getRecord(SESSION)!.providerContextBoundary!.operationId
|
||||
)
|
||||
expect(prepared.retained).toHaveLength(1)
|
||||
expect(prepared.retained[0].body).toMatchObject({
|
||||
kind: 'message',
|
||||
blocks: [{ text: 'kept after clear' }]
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
const result = await rig.host.rewind(CALLER, params)
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { epoch: floor.epoch, sequence: journal().cursor().sequence }
|
||||
})
|
||||
expect(
|
||||
journal()
|
||||
.snapshot()
|
||||
.items.filter((item) => item.sequence <= floor.sequence)
|
||||
).toEqual(prefix)
|
||||
expect(rawPrefix(floor.sequence)).toEqual(raw)
|
||||
expect(
|
||||
journal()
|
||||
.snapshot()
|
||||
.submissions.map((submission) => agentJournalSubmissionKey(submission.clientMessageId))
|
||||
).toEqual([old])
|
||||
expect(
|
||||
journal().canonicalItemId(rig.store.getRecord(SESSION)!.rewind!.providerItemId!)
|
||||
).not.toBe(target)
|
||||
const reopened = await replayedJournal()
|
||||
expect(reopened.snapshot()).toEqual(journal().snapshot())
|
||||
})
|
||||
|
||||
it('rolls suffix changes and success back together, then recovers once without provider retry', async () => {
|
||||
const { floor, raw, params } = await seed()
|
||||
const before = journal().snapshot()
|
||||
db().exec(`CREATE TEMP TRIGGER fail_rewind BEFORE UPDATE ON agent_session_records
|
||||
WHEN instr(NEW.record_json, '"phase":"completed"') > 0
|
||||
BEGIN SELECT RAISE(ABORT, 'disk full'); END`)
|
||||
await expect(rig.host.rewind(CALLER, params)).rejects.toThrow('disk full')
|
||||
expect(journal().snapshot()).toEqual(before)
|
||||
expect(rig.store.getRecord(SESSION)?.rewind?.phase).toBe('provider-succeeded')
|
||||
expect(
|
||||
rig.store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome.status
|
||||
).toBe('pending')
|
||||
db().exec('DROP TRIGGER fail_rewind')
|
||||
await recoverStructuredRewind(
|
||||
rig.host.deps,
|
||||
SESSION,
|
||||
journal(),
|
||||
rig.store.getRecord(SESSION)!.lease.runtimeFence
|
||||
)
|
||||
const recovered = journal().snapshot()
|
||||
await recoverStructuredRewind(
|
||||
rig.host.deps,
|
||||
SESSION,
|
||||
journal(),
|
||||
rig.store.getRecord(SESSION)!.lease.runtimeFence
|
||||
)
|
||||
expect(journal().snapshot()).toEqual(recovered)
|
||||
expect(rawPrefix(floor.sequence)).toEqual(raw)
|
||||
expect(rewind).toHaveBeenCalledOnce()
|
||||
expect(await rig.host.rewind(CALLER, params)).toMatchObject({ ok: true, replayed: true })
|
||||
})
|
||||
|
||||
it('replays a lost suffix acknowledgment without rewriting or repeating the provider effect', async () => {
|
||||
const { params } = await seed()
|
||||
const original = journal().context.rewind.bind(journal().context)
|
||||
const lost = vi.spyOn(journal().context, 'rewind').mockImplementationOnce(async (...args) => {
|
||||
await original(...args)
|
||||
throw new Error('lost acknowledgement')
|
||||
})
|
||||
await expect(rig.host.rewind(CALLER, params)).rejects.toThrow('lost acknowledgement')
|
||||
lost.mockRestore()
|
||||
const landed = journal().snapshot()
|
||||
expect(
|
||||
rig.store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome.status
|
||||
).toBe('succeeded')
|
||||
expect(await rig.host.rewind(CALLER, params)).toMatchObject({ ok: true, replayed: true })
|
||||
expect(journal().snapshot()).toEqual(landed)
|
||||
expect(rewind).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
+1
@@ -21,6 +21,7 @@ const ENDED_CHILD_FAILURE = {
|
||||
'user-stop': () => null,
|
||||
// The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`.
|
||||
'user-close': () => null,
|
||||
'context-clear': () => null,
|
||||
// The host stopping the child is Orca's cause, never the provider's: a start that never finished.
|
||||
'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }),
|
||||
exit: providerEndFailure,
|
||||
|
||||
@@ -42,7 +42,13 @@ export async function stopEndsWork(
|
||||
ending: StructuredAgentSessionStopEnding
|
||||
): Promise<boolean> {
|
||||
const { child, journal } = session
|
||||
if ('recorded' in ending || ending.quit || ending.resting || !child) {
|
||||
if (
|
||||
'recorded' in ending ||
|
||||
ending.quit ||
|
||||
ending.resting ||
|
||||
ending.cause === 'context-clear' ||
|
||||
!child
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// A failed drain has nothing more to deliver, so the journal's read as it stands holds. One
|
||||
|
||||
@@ -316,7 +316,6 @@ export class StructuredAgentSessionHost {
|
||||
|
||||
conversationCommand = (...args: Parameters<StructuredConversationCommandController['run']>) =>
|
||||
this.conversationCommands.run(...args)
|
||||
conversationReplacements = () => this.conversationCommands.replacements()
|
||||
/** Undefined means unavailable; an empty array is an authoritative catalog. */
|
||||
readCommands = (sessionId: string) => ({ commands: this.clientDelivery.readCommands(sessionId) })
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from './structured-agent-session-turns'
|
||||
import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt'
|
||||
import { queuedSendAnswer } from './structured-agent-session-queued-send-answer'
|
||||
import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer'
|
||||
|
||||
export type MutationPlan<TValue> = {
|
||||
method: string
|
||||
@@ -58,11 +59,10 @@ export type MutationPlan<TValue> = {
|
||||
recoverUnknownFromDurableState?: boolean
|
||||
} & (
|
||||
| {
|
||||
/** Its success is the row its run writes, so it commits in that row's transaction
|
||||
* (`AgentSessionTurnContext.operationReceipt`): a row left pending wrote nothing. That
|
||||
* success is fixed before the value exists, so it records no `settledOutcome`. */
|
||||
/** Commits success with its row; paths without a committed receipt use fallback settlement. */
|
||||
settlesWithWrite: true
|
||||
settledOutcome?: never
|
||||
successReceipt?: () => JournalOperationReceipt
|
||||
settledOutcome?: (value: TValue) => AgentSessionOperationOutcome
|
||||
}
|
||||
| {
|
||||
settlesWithWrite?: never
|
||||
|
||||
+5
-4
@@ -28,10 +28,11 @@ export async function runSettledAgentSessionMutation<TValue>(input: {
|
||||
) => input.store.recordOperationOutcome({ ...operation, outcome })
|
||||
const receipt = input.plan.settlesWithWrite
|
||||
? observedReceipt(
|
||||
input.store.operationOutcomeReceipt({
|
||||
...operation,
|
||||
outcome: { status: 'succeeded', sessionId: input.envelope.sessionId }
|
||||
})
|
||||
input.plan.successReceipt?.() ??
|
||||
input.store.operationOutcomeReceipt({
|
||||
...operation,
|
||||
outcome: { status: 'succeeded', sessionId: input.envelope.sessionId }
|
||||
})
|
||||
)
|
||||
: undefined
|
||||
const context = receipt ? { ...input.context, operationReceipt: receipt } : input.context
|
||||
|
||||
+16
-4
@@ -35,7 +35,11 @@ describe('options at rest', () => {
|
||||
resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }),
|
||||
probes: { codex: probe }
|
||||
})
|
||||
const resting = { child: null, params: { provider: 'codex' } }
|
||||
const resting = {
|
||||
child: null,
|
||||
params: { provider: 'codex' },
|
||||
journal: { context: { floor: () => null } }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
|
||||
const context = {
|
||||
deps: {
|
||||
@@ -71,7 +75,7 @@ describe('live Codex option reads', () => {
|
||||
const live = {
|
||||
child,
|
||||
params: { provider: 'codex' },
|
||||
journal: { threadGoal: () => null, contextUsage: () => null }
|
||||
journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields.
|
||||
const context = {
|
||||
@@ -101,7 +105,11 @@ describe('live Codex option reads', () => {
|
||||
|
||||
it('keeps a listing failure visible to the picker', async () => {
|
||||
const queue = new StructuredAgentSessionTaskQueue()
|
||||
const live = { child: { fence: 7 }, params: { provider: 'codex' } }
|
||||
const live = {
|
||||
child: { fence: 7 },
|
||||
params: { provider: 'codex' },
|
||||
journal: { context: { floor: () => null } }
|
||||
}
|
||||
const failure = new Error('model/list unavailable')
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the failed read ends before touching the remaining context fields.
|
||||
const context = {
|
||||
@@ -133,7 +141,11 @@ describe('live Codex option reads', () => {
|
||||
await listing.promise
|
||||
return apply
|
||||
})
|
||||
const live = { child: { fence: 7 }, params: { provider: 'codex' } }
|
||||
const live = {
|
||||
child: { fence: 7 },
|
||||
params: { provider: 'codex' },
|
||||
journal: { context: { floor: () => null } }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this read touches only the declared context fields.
|
||||
const context = {
|
||||
deps: {
|
||||
|
||||
@@ -148,6 +148,8 @@ export async function readStructuredAgentSessionOptions(
|
||||
const phase = store.getRecord(sessionId)?.rewind?.phase
|
||||
const agent = session.params.provider
|
||||
const capabilities = agents.capabilities(agent)
|
||||
const floor = session.journal.context.floor()
|
||||
const contextFloor = floor ? { contextFloor: floor } : {}
|
||||
return {
|
||||
...options,
|
||||
rewind:
|
||||
@@ -158,9 +160,11 @@ export async function readStructuredAgentSessionOptions(
|
||||
reason: 'unsupported'
|
||||
}),
|
||||
conversationCommands: capabilities?.compact ? ['clear', 'compact'] : ['clear'],
|
||||
...(capabilities?.threadGoal ? { threadGoal: { current: session.journal.threadGoal() } } : {}),
|
||||
...(capabilities?.threadGoal
|
||||
? { threadGoal: { current: session.journal.threadGoal(), ...contextFloor } }
|
||||
: {}),
|
||||
...(capabilities?.contextUsage
|
||||
? { contextUsage: { current: session.journal.contextUsage() } }
|
||||
? { contextUsage: { current: session.journal.contextUsage(), ...contextFloor } }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -538,8 +538,8 @@ describe("the queue's next card on a history page", () => {
|
||||
})
|
||||
|
||||
describe('where the host would refuse the send', () => {
|
||||
/** An idle source a /clear replaced, still holding a card: a crash before the carry leaves it. */
|
||||
async function cardLeftOnAClearedSource() {
|
||||
/** A new card in the same conversation after /clear inherits no earlier pause. */
|
||||
async function cardAddedAfterClear() {
|
||||
await rig.settleAccepted(await rig.workingSend(), 'a')
|
||||
const fields = { command: 'clear' as const }
|
||||
const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, {
|
||||
@@ -549,12 +549,12 @@ describe('where the host would refuse the send', () => {
|
||||
expect(cleared).toMatchObject({ ok: true })
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
|
||||
if (!journal) {
|
||||
throw new Error('expected the source open')
|
||||
throw new Error('expected the conversation open')
|
||||
}
|
||||
const card = 'left-behind'
|
||||
await journal.queuedMessages.insert({
|
||||
messageId: card,
|
||||
body: hostTestMessage('left on the source'),
|
||||
body: hostTestMessage('queued after clear'),
|
||||
fingerprint: 'fp-left-behind',
|
||||
hostInstance: structuredAgentSessionHostInstance()
|
||||
})
|
||||
@@ -565,17 +565,17 @@ describe('where the host would refuse the send', () => {
|
||||
return { card, journal, record, fence: record.lease.runtimeFence }
|
||||
}
|
||||
|
||||
it('a source a /clear replaced names no next card; the same idle state with no block names it', async () => {
|
||||
const { card, journal, record, fence } = await cardLeftOnAClearedSource()
|
||||
it('a card queued after clear is sendable with the completed clear record still present', async () => {
|
||||
const { card, journal, record, fence } = await cardAddedAfterClear()
|
||||
const gate = structuredQueueSendGate(rig.store, HOST_TEST_SESSION)
|
||||
expect(readQueuePublication(journal, gate).nextQueuedMessageId).toBeNull()
|
||||
expect(readQueuePublication(journal, gate).nextQueuedMessageId).toBe(card)
|
||||
const { conversationCommand: _cleared, ...unblocked } = record
|
||||
const next = readQueuePublication(journal, () => ({ record: unblocked, fence }))
|
||||
expect(next.nextQueuedMessageId).toBe(card)
|
||||
})
|
||||
|
||||
it('a rewind whose outcome is unknown names no next card', async () => {
|
||||
const { journal, record, fence } = await cardLeftOnAClearedSource()
|
||||
const { journal, record, fence } = await cardAddedAfterClear()
|
||||
const { conversationCommand: _cleared, ...unblocked } = record
|
||||
const rewind = {
|
||||
operationId: hostTestOperationId(),
|
||||
|
||||
+70
-91
@@ -1,11 +1,5 @@
|
||||
// Queued drafts across conversation commands: a /compact is a queued message
|
||||
// and then a turn, so a capable send during it becomes a card that waits for it
|
||||
// like any turn, while Delete and Send-now answer at once; a /clear in flight
|
||||
// admits no draft onto the source it is superseding; and a draft /clear carries
|
||||
// to its replacement is fingerprinted for the replacement, so the provider's
|
||||
// echo folds into its sent bubble.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { activeProviderContext } from '../../../shared/agent-session-provider-context'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
@@ -14,7 +8,7 @@ import {
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import {
|
||||
HOST_TEST_THREAD as THREAD,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestMessage,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
@@ -97,122 +91,107 @@ describe('a /compact in flight', () => {
|
||||
})
|
||||
|
||||
describe('/clear', () => {
|
||||
it('in flight, refuses a capable send as today: no card lands on the source it supersedes', async () => {
|
||||
const commit = rig.store.commitConversationClear
|
||||
it('refuses a send arriving while clear is stopping the context', async () => {
|
||||
let release: (() => void) | undefined
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const committing = vi.fn()
|
||||
const spy = vi
|
||||
.spyOn(rig.store, 'commitConversationClear')
|
||||
.mockImplementationOnce(async (clear) => {
|
||||
committing()
|
||||
await released
|
||||
return commit(clear)
|
||||
})
|
||||
const stopping = vi.fn()
|
||||
const spy = rig.closeSession.mockImplementationOnce(async () => {
|
||||
stopping()
|
||||
await released
|
||||
return true
|
||||
})
|
||||
try {
|
||||
const cleared = command('clear')
|
||||
await eventually(() => expect(committing).toHaveBeenCalledOnce())
|
||||
// Judged on arrival, answered on its turn: behind the clear.
|
||||
await eventually(() => expect(stopping).toHaveBeenCalledOnce())
|
||||
const sent = rig.send('sent while clearing', 'queue-if-active').result
|
||||
release?.()
|
||||
expect(await sent).toEqual(WAIT_REFUSAL)
|
||||
const done = await cleared
|
||||
const replacementId = done.ok ? done.value.replacementSessionId : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
expect(await cleared).toMatchObject({ ok: true, value: { command: 'clear' } })
|
||||
expect(await rig.drafts()).toHaveLength(0)
|
||||
expect(await rig.drafts(replacementId)).toHaveLength(0)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
release?.()
|
||||
spy.mockResolvedValue(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries drafts to a replacement no agent has started, before it answers; the first send starts the agent ahead of them', async () => {
|
||||
it('keeps waiting card identities and starts a fresh context ahead of their drain', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const firstId = await queuedId(rig.send('first draft', 'queue-if-active').result)
|
||||
const secondId = await queuedId(rig.send('second draft', 'queue-if-active').result)
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'a')
|
||||
const cleared = await command('clear')
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
// The clear started nothing, yet the drafts had already moved when it answered.
|
||||
expect(rig.store.getRecord(replacementId)).toMatchObject({
|
||||
providerHandleChain: [],
|
||||
lease: { claimStatus: 'released' }
|
||||
})
|
||||
expect(rig.host.collaboratorsForTests().sessions.get(replacementId)?.child ?? null).toBeNull()
|
||||
expect(await rig.drafts()).toHaveLength(0)
|
||||
expect(await rig.drafts(replacementId)).toEqual([
|
||||
expect(await command('clear')).toMatchObject({ ok: true })
|
||||
expect(rig.store.getRecord(SESSION)?.providerHandleChain).toEqual([])
|
||||
expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child ?? null).toBeNull()
|
||||
expect(await rig.drafts()).toEqual([
|
||||
{ messageId: firstId, state: 'waiting' },
|
||||
{ messageId: secondId, state: 'waiting' }
|
||||
])
|
||||
// Held, unshown: nothing runs in the fresh conversation.
|
||||
expect(await rig.queuePause(replacementId)).toBeNull()
|
||||
|
||||
const body = hostTestMessage('first in the new chat')
|
||||
const fields = { body, delivery: 'queue-if-active' as const }
|
||||
const sentId = hostTestOperationId()
|
||||
expect(
|
||||
await rig.host.send(CALLER, {
|
||||
envelope: rig.envelope(fields, 'agentSession.send', sentId, replacementId),
|
||||
...fields
|
||||
})
|
||||
).toMatchObject({ ok: true, value: { submission: expect.anything() } })
|
||||
expect(await rig.handoff(firstId)).toBeUndefined()
|
||||
const sent = rig.send('first in the new context', 'queue-if-active')
|
||||
expect(await sent.result).toMatchObject({ ok: true, value: { submission: expect.anything() } })
|
||||
await eventually(() =>
|
||||
expect(rig.store.getRecord(replacementId)?.providerHandleChain).toHaveLength(1)
|
||||
expect(rig.store.getRecord(SESSION)?.providerHandleChain).toHaveLength(1)
|
||||
)
|
||||
await eventually(() =>
|
||||
expect(activeProviderContext(rig.store.getRecord(SESSION)!).head).not.toBeNull()
|
||||
)
|
||||
const thread = activeProviderContext(rig.store.getRecord(SESSION)!).head!.handle.nativeId
|
||||
await rig.host.settleLateDispatch({
|
||||
sessionId: replacementId,
|
||||
clientMessageId: sentId,
|
||||
providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-first', ordinal: 0 }
|
||||
sessionId: SESSION,
|
||||
clientMessageId: sent.id,
|
||||
providerIdentity: { provider: 'codex', threadId: thread, turnId: 'turn-first', ordinal: 0 }
|
||||
})
|
||||
await eventually(async () =>
|
||||
expect(
|
||||
(await rig.host.journalSnapshot(replacementId)).submissions.map((entry) =>
|
||||
entry.clientMessageId === sentId ? 'sent' : entry.queuedMessageId
|
||||
)
|
||||
).toEqual(['sent', firstId])
|
||||
)
|
||||
expect(await rig.queuePause(replacementId)).toBeNull()
|
||||
await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined())
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
})
|
||||
|
||||
it("a carried draft sent on the replacement: the provider's echo folds into its one bubble", async () => {
|
||||
it('compacts the new context without removing clear ancestry or its divider', async () => {
|
||||
expect(await command('clear')).toMatchObject({ ok: true })
|
||||
const boundary = rig.store.getRecord(SESSION)!.providerContextBoundary
|
||||
expect(await command('compact')).toMatchObject({ ok: true })
|
||||
await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
|
||||
expect(activeProviderContext(rig.store.getRecord(SESSION)!).head?.replaces).toBeUndefined()
|
||||
expect(rig.store.getRecord(SESSION)!.providerHandleChain).toHaveLength(1)
|
||||
rig.finishCompact()
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal
|
||||
await eventually(() =>
|
||||
expect(rig.store.getRecord(SESSION)?.conversationCommand?.state).toBe('completed')
|
||||
)
|
||||
expect(rig.store.getRecord(SESSION)!.providerContextBoundary).toEqual(boundary)
|
||||
expect(journal.context.floor()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a waiting card sent after clear gets one provider echo bubble in that context', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedId(rig.send('carried text', 'queue-if-active').result)
|
||||
const draftId = await queuedId(rig.send('waiting text', 'queue-if-active').result)
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'a')
|
||||
const cleared = await command('clear')
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
expect(await rig.sendNow(draftId, hostTestOperationId(), replacementId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: expect.anything() }
|
||||
})
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal
|
||||
const sent = journal?.submissions().findLast((entry) => entry.queuedMessageId === draftId)
|
||||
if (!journal || !sent) {
|
||||
throw new Error('expected the carried draft sent on the replacement')
|
||||
}
|
||||
expect(await command('clear')).toMatchObject({ ok: true })
|
||||
expect(await rig.sendNow(draftId, hostTestOperationId())).toMatchObject({ ok: true })
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal
|
||||
const sent = journal.submissions().findLast((entry) => entry.queuedMessageId === draftId)!
|
||||
await eventually(() =>
|
||||
expect(activeProviderContext(rig.store.getRecord(SESSION)!).head).not.toBeNull()
|
||||
)
|
||||
const thread = activeProviderContext(rig.store.getRecord(SESSION)!).head!.handle.nativeId
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: THREAD, turnId: 'turn-echo', ordinal: 0 },
|
||||
hostTestMessage('carried text'),
|
||||
{ provider: 'codex', threadId: thread, turnId: 'turn-echo', ordinal: 0 },
|
||||
hostTestMessage('waiting text'),
|
||||
{ fence: sent.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
const snapshot = await rig.host.journalSnapshot(replacementId)
|
||||
const userBubbles = snapshot.items.filter(
|
||||
(item) => item.body.kind === 'message' && item.body.role === 'user'
|
||||
)
|
||||
expect(userBubbles).toHaveLength(1)
|
||||
expect(snapshot.submissions.find((entry) => entry.queuedMessageId === draftId)).toMatchObject({
|
||||
dispatchState: 'accepted'
|
||||
})
|
||||
const floor = journal.context.floor()!.sequence
|
||||
expect(
|
||||
journal
|
||||
.snapshot()
|
||||
.items.filter(
|
||||
(item) =>
|
||||
item.sequence > floor && item.body.kind === 'message' && item.body.role === 'user'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(journal.submission(sent.clientMessageId)?.dispatchState).toBe('accepted')
|
||||
})
|
||||
})
|
||||
|
||||
+29
-21
@@ -495,39 +495,47 @@ describe('a /compact that waits in line', () => {
|
||||
expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected' })
|
||||
})
|
||||
|
||||
it('a /clear drops a waiting command card instead of carrying it to the new chat', async () => {
|
||||
it('a /clear withdraws a waiting command and keeps ordinary cards paused in order', async () => {
|
||||
const working = await rig.workingSend()
|
||||
await queuedCompact()
|
||||
const draft = await rig.send('carried draft', 'queue-if-active').result
|
||||
if (!draft.ok || !('queued' in draft.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const first = rig.send('first draft', 'queue-if-active')
|
||||
await first.result
|
||||
const compactId = await queuedCompact()
|
||||
const last = rig.send('last draft', 'queue-if-active')
|
||||
await last.result
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'a')
|
||||
const cleared = await clear()
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
expect(await rig.drafts()).toEqual([])
|
||||
expect(await rig.drafts(replacementId)).toEqual([
|
||||
{ messageId: draft.value.queued.messageId, state: 'waiting' }
|
||||
expect(await clear()).toMatchObject({
|
||||
ok: true,
|
||||
value: { command: 'clear', state: 'completed' }
|
||||
})
|
||||
expect(await rig.drafts()).toEqual([
|
||||
{ messageId: first.id, state: 'waiting' },
|
||||
{ messageId: last.id, state: 'waiting' }
|
||||
])
|
||||
expect(
|
||||
rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal.queuedMessages.get(compactId)
|
||||
).toMatchObject({ state: 'withdrawn' })
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
expect(
|
||||
rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal.queuedMessages.pauses()
|
||||
).toContainEqual(
|
||||
expect.objectContaining({ reason: 'cleared', messageIds: [first.id, last.id] })
|
||||
)
|
||||
expect(await rig.handoff(first.id)).toBeUndefined()
|
||||
expect(await rig.handoff(last.id)).toBeUndefined()
|
||||
expect(rig.compact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a /clear with only a command card waiting opens no replacement conversation for it', async () => {
|
||||
it('a /clear with only a waiting command keeps the same chat with no queue pause', async () => {
|
||||
const working = await rig.workingSend()
|
||||
await queuedCompact()
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'a')
|
||||
const cleared = await clear()
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
expect(await clear()).toMatchObject({ ok: true })
|
||||
expect(await rig.drafts()).toEqual([])
|
||||
expect(rig.host.collaboratorsForTests().sessions.has(replacementId)).toBe(false)
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true)
|
||||
expect(rig.store.listRecords()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+15
-5
@@ -3,6 +3,7 @@
|
||||
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import { activeProviderContext } from '../../../shared/agent-session-provider-context'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
} from './structured-agent-session-host-test-data'
|
||||
|
||||
export type QueuedRigProviderOptions = {
|
||||
rewind?: NonNullable<StructuredAgentSessionAdapter['rewind']>
|
||||
recoverRewind?: NonNullable<StructuredAgentSessionAdapter['recoverRewind']>
|
||||
/** A child started for a chat whose chain already names a thread resumes it, so a chat whose
|
||||
* child closed or died can start another. */
|
||||
restartable?: true
|
||||
@@ -62,9 +65,12 @@ export function createQueuedRigProvider(
|
||||
throw failure
|
||||
}
|
||||
events = sink
|
||||
const resumes =
|
||||
options.restartable === true &&
|
||||
(store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0
|
||||
const record = store.getRecord(identity.sessionId)
|
||||
const context = record ? activeProviderContext(record) : null
|
||||
const resumes = options.restartable === true && context?.head != null
|
||||
const thread = context?.pendingClear
|
||||
? `${THREAD}-context-${record!.providerContextBoundary!.operationId}`
|
||||
: (context?.head?.handle.nativeId ?? THREAD)
|
||||
return {
|
||||
process: {
|
||||
hostId: 'local',
|
||||
@@ -76,7 +82,7 @@ export function createQueuedRigProvider(
|
||||
...(options.starting ? { providerChildPhase: 'starting' as const } : {}),
|
||||
link: {
|
||||
linkId: `link-${fence}`,
|
||||
handle: codexProviderHandle(THREAD),
|
||||
handle: codexProviderHandle(thread),
|
||||
origin: resumes ? ('resumed' as const) : ('created' as const),
|
||||
mintedAtFence: fence,
|
||||
observedAt: NOW
|
||||
@@ -91,7 +97,11 @@ export function createQueuedRigProvider(
|
||||
...(options.stopEndsSession ? { stopEndsSession: () => true } : {}),
|
||||
...(options.startUnanswered ? { startAnswered: () => false } : {}),
|
||||
answerPrompt: vi.fn(async () => undefined),
|
||||
setOption: vi.fn(async () => undefined)
|
||||
setOption: vi.fn(async () => undefined),
|
||||
...(options.rewind
|
||||
? { rewind: options.rewind, rewindSupport: () => ({ supported: true as const }) }
|
||||
: {}),
|
||||
...(options.recoverRewind ? { recoverRewind: options.recoverRewind } : {})
|
||||
}
|
||||
|
||||
/** What the provider's translator writes when a /compact's turn ends, as a success. */
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, vi } from 'vitest'
|
||||
import { activeProviderContext } from '../../../shared/agent-session-provider-context'
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
@@ -189,7 +190,7 @@ export async function createQueuedMessageTestRig(
|
||||
clientMessageId: id,
|
||||
providerIdentity: {
|
||||
provider: 'codex',
|
||||
threadId: THREAD,
|
||||
threadId: activeProviderContext(store.getRecord(SESSION)!).head?.handle.nativeId ?? THREAD,
|
||||
turnId: `turn-${itemId}`,
|
||||
ordinal: 0
|
||||
}
|
||||
|
||||
+31
-44
@@ -1,8 +1,8 @@
|
||||
// Mid-turn queueing against the real host, store and journal: a capable send
|
||||
// while the session owes work becomes a draft, the drain converts exactly one
|
||||
// draft when the work settles, Stop holds the queue (never withdrawing text)
|
||||
// until a user send starts its turn and lifts the pause, /clear carries the
|
||||
// cards to its replacement session, and a refused conversion comes back as a
|
||||
// until a user send starts its turn and lifts the pause, /clear keeps cards in
|
||||
// the same conversation, and a refused conversion comes back as a
|
||||
// returned card while a withdrawn one waits again.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -607,19 +607,15 @@ describe('/clear', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('carries the drafts to the replacement session as visible cards on a paused queue — the same for every client', async () => {
|
||||
it('preserves waiting cards in place and pauses them until user action for every client', async () => {
|
||||
const [firstId, secondId] = await pausedDrafts()
|
||||
const operationId = hostTestOperationId()
|
||||
const cleared = await clear(operationId)
|
||||
expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } })
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
const replacementId = cleared.ok ? SESSION : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
throw new Error('expected clear to succeed')
|
||||
}
|
||||
// The source's cards are spent tombstones; the replacement shows them as plain waiting cards,
|
||||
// held by the clear with no row (nothing runs there), until the user acts: Resume, or their
|
||||
// next send starting its turn.
|
||||
expect(await drafts()).toHaveLength(0)
|
||||
expect(await drafts(replacementId)).toEqual([
|
||||
{ messageId: firstId, state: 'waiting' },
|
||||
{ messageId: secondId, state: 'waiting' }
|
||||
@@ -627,7 +623,11 @@ describe('/clear', () => {
|
||||
expect(await rig.queuePause(replacementId)).toBeNull()
|
||||
// Paused from before the first carried card lands: the idle replacement auto-sends nothing.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect((await host.journalSnapshot(replacementId)).submissions).toHaveLength(0)
|
||||
expect(
|
||||
(await host.journalSnapshot(replacementId)).submissions.filter(
|
||||
(entry) => entry.queuedMessageId !== undefined
|
||||
)
|
||||
).toHaveLength(0)
|
||||
// A lost acknowledgement's replay re-runs nothing and duplicates nothing.
|
||||
const replayed = await clear(operationId)
|
||||
expect(replayed).toMatchObject({ ok: true, replayed: true })
|
||||
@@ -639,7 +639,7 @@ describe('/clear', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("carries who each card is from on its body, fingerprinted for the replacement's echo", async () => {
|
||||
it("preserves each card's sender and fingerprint across clear", async () => {
|
||||
const party = { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null }
|
||||
const from: AgentMessageSource = {
|
||||
kind: 'agent',
|
||||
@@ -652,9 +652,9 @@ describe('/clear', () => {
|
||||
await stop()
|
||||
await settleAccepted(working, 'a')
|
||||
const cleared = await clear(hostTestOperationId())
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
const replacementId = cleared.ok ? SESSION : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
throw new Error('expected clear to succeed')
|
||||
}
|
||||
const rows =
|
||||
host.collaboratorsForTests().sessions.get(replacementId)?.journal.queuedMessages.list() ?? []
|
||||
@@ -665,12 +665,12 @@ describe('/clear', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("the replacement's unshown 'cleared' pause lifts through Resume exactly like a Stop's", async () => {
|
||||
it("the clear pause lifts through Resume exactly like a Stop's", async () => {
|
||||
const [firstId] = await pausedDrafts()
|
||||
const cleared = await clear(hostTestOperationId())
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
const replacementId = cleared.ok ? SESSION : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
throw new Error('expected clear to succeed')
|
||||
}
|
||||
expect(await rig.queuePause(replacementId)).toBeNull()
|
||||
const resumed = await host.queuedMessagesResume(CALLER, {
|
||||
@@ -692,29 +692,19 @@ describe('/clear', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('a carry whose insert fails leaves no pause over the empty replacement', async () => {
|
||||
await pausedDrafts()
|
||||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const insert = vi
|
||||
.spyOn(JournalQueuedMessages.prototype, 'insert')
|
||||
.mockRejectedValueOnce(new Error('disk full'))
|
||||
let replacementId: string | undefined
|
||||
it('clears without copying or inserting any queued card', async () => {
|
||||
const ids = await pausedDrafts()
|
||||
const insert = vi.spyOn(JournalQueuedMessages.prototype, 'insert')
|
||||
try {
|
||||
const cleared = await clear(hostTestOperationId())
|
||||
replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
expect(await clear(hostTestOperationId())).toMatchObject({ ok: true })
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect((await drafts()).map((card) => card.messageId)).toEqual(ids)
|
||||
} finally {
|
||||
insert.mockRestore()
|
||||
warned.mockRestore()
|
||||
}
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
expect(await drafts(replacementId)).toHaveLength(0)
|
||||
const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal
|
||||
expect(journal && structuredQueuePauses(journal)).toEqual([])
|
||||
})
|
||||
|
||||
it('a returned card carries over as a plain waiting draft on the paused replacement', async () => {
|
||||
it('preserves a returned card and its refusal after clear', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('refused then cleared', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
@@ -728,15 +718,12 @@ describe('/clear', () => {
|
||||
expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }])
|
||||
)
|
||||
const cleared = await clear(hostTestOperationId())
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
const replacementId = cleared.ok ? SESSION : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
throw new Error('expected clear to succeed')
|
||||
}
|
||||
// The refusal belonged to the source's submissions; on the replacement the
|
||||
// text is simply a waiting draft again, behind the replacement's pause.
|
||||
expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'waiting' }])
|
||||
expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'returned' }])
|
||||
expect(await rig.queuePause(replacementId)).toBeNull()
|
||||
expect(await drafts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a clear an older build left prepared holds no draft: it drains when the turn settles', async () => {
|
||||
@@ -758,15 +745,15 @@ describe('/clear', () => {
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
|
||||
it('a clear with no drafts carries nothing and answers exactly as before', async () => {
|
||||
it('a clear with no drafts keeps the existing conversation open', async () => {
|
||||
const cleared = await clear(hostTestOperationId())
|
||||
expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } })
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
const replacementId = cleared.ok ? SESSION : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
throw new Error('expected clear to succeed')
|
||||
}
|
||||
// Nothing to carry, so nothing opened the new conversation.
|
||||
expect(host.hasSession(replacementId)).toBe(false)
|
||||
// The journal remains open, with no child started.
|
||||
expect(host.hasSession(replacementId)).toBe(true)
|
||||
expect(await drafts(replacementId)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-69
@@ -1,5 +1,5 @@
|
||||
// `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete`, and
|
||||
// /clear's carry of the source's drafts to its replacement session. Settling
|
||||
// mutations of the conversation's queued drafts. Settling
|
||||
// operations stamp op-scoped tombstone receipts, so a lost acknowledgement
|
||||
// replays from the rows themselves — never from the operation ledger, which
|
||||
// records only that an operation happened. No mutation returns draft text:
|
||||
@@ -23,12 +23,10 @@ import {
|
||||
import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table'
|
||||
import type { MutationPlan } from './structured-agent-session-mutation-plans'
|
||||
import { structuredQueueHold } from './structured-agent-session-queued-messages'
|
||||
import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
|
||||
import {
|
||||
resumeStructuredQueue,
|
||||
structuredAgentSessionHostInstance
|
||||
} from './structured-agent-session-queued-pause'
|
||||
import { unsettledQueuedMessages } from './structured-agent-session-queued-stop'
|
||||
import {
|
||||
mutateStructuredAgentSession,
|
||||
type StructuredAgentSessionMutationContext
|
||||
@@ -72,72 +70,6 @@ export async function withdrawQueuedMessagesForOperation(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* /clear's carry: the source's unsettled drafts become rows on the replacement
|
||||
* session — the SAME for every client version, with no text on the wire — so the
|
||||
* cards stay visible where the user now is. The replacement's queue starts
|
||||
* paused ('cleared'), lifted exactly like a Stop's: the cards were written for the context /clear just
|
||||
* discarded, so they wait for the user's next turn there, or Resume, rather than
|
||||
* sending into the fresh context unasked. Each card records the conversation it
|
||||
* came from, which IS that pause, so the drain never sees a carried card unpaused
|
||||
* and no pause outlives the cards. Runs after the clear commits, opening the
|
||||
* replacement's conversation only when there are drafts to carry; the source
|
||||
* rows are then tombstoned. A command card (a waiting /compact) is tombstoned
|
||||
* without a copy. Bookkeeping around the clear: a failure, or a crash
|
||||
* before the carry, leaves the cards on the superseded source — whose
|
||||
* supersession fence already blocks the drain — reported, never gating the
|
||||
* clear. A crash between the copy and the tombstone leaves both, which the
|
||||
* fence also makes harmless: nothing is lost and nothing runs.
|
||||
*/
|
||||
export async function carryQueuedMessagesToClearReplacement(
|
||||
ctx: AgentSessionTurnContext,
|
||||
input: {
|
||||
replacementSessionId: string
|
||||
openReplacementJournal: () => Promise<AgentSessionJournal | undefined>
|
||||
callerKey: string
|
||||
operationId: string
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const rows = unsettledQueuedMessages(ctx.journal)
|
||||
if (rows.length === 0) {
|
||||
return
|
||||
}
|
||||
// A command belongs to the discarded context, so it is withdrawn instead of carried.
|
||||
const carried = rows.filter((row) => !row.body.command)
|
||||
if (carried.length > 0) {
|
||||
const replacement = await input.openReplacementJournal()
|
||||
if (!replacement) {
|
||||
throw new Error('the replacement journal is not open')
|
||||
}
|
||||
for (const row of carried) {
|
||||
// A returned card carries over as a plain waiting draft — its refusal
|
||||
// belonged to the source's submissions. The fingerprint is re-scoped to the
|
||||
// replacement, or its echo could never alias the sent bubble.
|
||||
await replacement.queuedMessages.insert({
|
||||
messageId: row.messageId,
|
||||
body: row.body,
|
||||
fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body),
|
||||
hostInstance: structuredAgentSessionHostInstance(),
|
||||
carriedFrom: ctx.sessionId
|
||||
})
|
||||
}
|
||||
}
|
||||
await withdrawQueuedMessagesForOperation(ctx.journal, {
|
||||
sessionId: ctx.sessionId,
|
||||
messageIds: rows.map((row) => row.messageId),
|
||||
callerKey: input.callerKey,
|
||||
operationId: input.operationId
|
||||
})
|
||||
} catch (error) {
|
||||
ctx.logger.warn("carrying queued drafts to /clear's replacement failed", {
|
||||
scope: 'clear-queued-carry',
|
||||
sessionId: ctx.sessionId,
|
||||
replacementSessionId: input.replacementSessionId,
|
||||
error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Draft actions run like any mutation: admitted on the session's lane, the
|
||||
* conversation opened for the write. */
|
||||
|
||||
+11
-5
@@ -242,13 +242,14 @@ describe("a /clear's carried cards", () => {
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
const cleared = await clear()
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
const replacementId = cleared.ok ? HOST_TEST_SESSION : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`)
|
||||
throw new Error(`expected clear to succeed: ${JSON.stringify(cleared)}`)
|
||||
}
|
||||
// Held there, unshown: nothing runs in the fresh conversation.
|
||||
expect(await rig.queuePause(replacementId)).toBeNull()
|
||||
expect(structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason)).toEqual([
|
||||
'stopped',
|
||||
'cleared'
|
||||
])
|
||||
// Idle there, so the person's send goes straight out rather than queueing.
|
||||
@@ -276,7 +277,12 @@ describe("a /clear's carried cards", () => {
|
||||
await rig.host.settleLateDispatch({
|
||||
sessionId: replacementId,
|
||||
clientMessageId: sent.value.clientMessageId,
|
||||
providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-hi', ordinal: 0 }
|
||||
providerIdentity: {
|
||||
provider: 'codex',
|
||||
threadId: rig.store.getRecord(replacementId)!.providerHandleChain.at(-1)!.handle.nativeId,
|
||||
turnId: 'turn-hi',
|
||||
ordinal: 0
|
||||
}
|
||||
})
|
||||
expect(journal(replacementId).queuedMessages.list()[0]?.messageId).toBe(carried)
|
||||
expect(structuredQueuePauses(journal(replacementId))).toEqual([])
|
||||
@@ -302,10 +308,10 @@ describe('no stored pause', () => {
|
||||
envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()),
|
||||
...fields
|
||||
})
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
const replacementId = cleared.ok ? HOST_TEST_SESSION : undefined
|
||||
expect(
|
||||
replacementId && structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason)
|
||||
).toEqual(['cleared'])
|
||||
).toEqual(['stopped', 'cleared'])
|
||||
expect(pauseTables()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ function readAtRest(agents: StructuredAgentRegistry) {
|
||||
const resting = {
|
||||
child: null,
|
||||
params: { provider: RECORD.provider },
|
||||
journal: { threadGoal: () => null, contextUsage: () => null }
|
||||
journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
|
||||
const context = {
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { structuredAgentSessionResumableSet } from './structured-agent-session-restart-resume-set'
|
||||
import {
|
||||
structuredAgentSessionWorkingAtStop,
|
||||
structuredAgentSessionWorkInFlight
|
||||
} from './structured-agent-session-working-at-teardown'
|
||||
import {
|
||||
record,
|
||||
marker,
|
||||
journal,
|
||||
turnItem,
|
||||
submission,
|
||||
NOW,
|
||||
SESSION,
|
||||
TEARDOWN_CURRENT
|
||||
} from './structured-agent-session-restart-resume-test-harness'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
|
||||
const boundary = { operationId: 'clear', afterFence: 1, clearedAt: NOW }
|
||||
const clear: AgentJournalRenderItem = {
|
||||
itemId: 'clear',
|
||||
sequence: 5,
|
||||
revision: 1,
|
||||
observedAt: NOW,
|
||||
body: { kind: 'status', text: 'Context cleared', contextClear: boundary }
|
||||
}
|
||||
const value = (): ReturnType<typeof record> & { providerContextBoundary: typeof boundary } => ({
|
||||
...record(),
|
||||
providerHandleChain: [],
|
||||
providerContextBoundary: boundary
|
||||
})
|
||||
const set = (m = marker(), r = value()) =>
|
||||
structuredAgentSessionResumableSet({
|
||||
markers: [m],
|
||||
getRecord: () => r,
|
||||
supportsRecord: () => true,
|
||||
latestPrompt: () => 'prior prompt',
|
||||
movedOn: () => false,
|
||||
savedByNewerOrca: () => false
|
||||
})
|
||||
|
||||
describe('restart offers after context clear', () => {
|
||||
it('retires the old-context offer before any new provider handle exists', () => {
|
||||
expect(set()).toEqual({ candidates: [], superseded: [marker()] })
|
||||
expect(set({ ...marker(), contextClearOperationId: 'previous-clear' })).toEqual({
|
||||
candidates: [],
|
||||
superseded: [{ ...marker(), contextClearOperationId: 'previous-clear' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat an old unknown send as new-context work during teardown', () => {
|
||||
const unknown = { ...submission('old-send', 'unknown'), acceptedSequence: 2 }
|
||||
expect(
|
||||
structuredAgentSessionWorkInFlight([turnItem('old', 'running'), clear], [unknown])
|
||||
).toBeNull()
|
||||
expect(
|
||||
structuredAgentSessionWorkingAtStop({
|
||||
sessionId: SESSION,
|
||||
session: {
|
||||
journal: journal([turnItem('old', 'running'), clear], [unknown]),
|
||||
child: { fence: 2 }
|
||||
},
|
||||
getRecord: value,
|
||||
childWork: () => undefined,
|
||||
trigger: 'quit',
|
||||
teardownId: TEARDOWN_CURRENT,
|
||||
now: NOW
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('stamps a current-context offer and keeps it eligible after restart', () => {
|
||||
const proved = { ...record().providerHandleChain[0], linkId: 'new', mintedAtFence: 2 }
|
||||
const currentRecord = {
|
||||
...value(),
|
||||
providerHandleChain: [proved]
|
||||
}
|
||||
const turn = { ...turnItem('new-turn', 'running'), sequence: 6 }
|
||||
const offer = structuredAgentSessionWorkingAtStop({
|
||||
sessionId: SESSION,
|
||||
session: { journal: journal([clear, turn]), child: { fence: 2 } },
|
||||
getRecord: () => currentRecord,
|
||||
childWork: () => undefined,
|
||||
trigger: 'quit',
|
||||
teardownId: TEARDOWN_CURRENT,
|
||||
now: NOW
|
||||
})!
|
||||
expect(offer.contextClearOperationId).toBe('clear')
|
||||
expect(set(offer, currentRecord).candidates).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -113,6 +113,10 @@ export function structuredAgentSessionResumableSet(
|
||||
if (!record || !input.supportsRecord(record)) {
|
||||
continue
|
||||
}
|
||||
if (record.providerContextBoundary?.operationId !== marker.contextClearOperationId) {
|
||||
superseded.push(marker)
|
||||
continue
|
||||
}
|
||||
// The lease must be free and adjudicated. A contested or still-reconciling record is somebody
|
||||
// else's to resolve, and resuming into it is how a session gets two writers.
|
||||
if (input.leaseState !== 'may-be-held' && !isResumableStructuredAgentSessionRecord(record)) {
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { expect, type Mock } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { HOST_TEST_SESSION, HOST_TEST_THREAD } from './structured-agent-session-host-test-data'
|
||||
|
||||
type RewindReplayFixture = {
|
||||
host: StructuredAgentSessionHost
|
||||
store: AgentSessionRecordStore
|
||||
rewind: Mock<NonNullable<StructuredAgentSessionAdapter['rewind']>>
|
||||
recoverRewind: Mock<NonNullable<StructuredAgentSessionAdapter['recoverRewind']>>
|
||||
seed: () => Promise<string>
|
||||
params: (itemId: string) => Promise<Parameters<StructuredAgentSessionHost['rewind']>[1]>
|
||||
}
|
||||
const caller = { callerKey: 'desktop' }
|
||||
|
||||
export async function expectSupersededRewindReplay({
|
||||
host,
|
||||
store,
|
||||
rewind,
|
||||
recoverRewind,
|
||||
seed,
|
||||
params
|
||||
}: RewindReplayFixture) {
|
||||
const target = await seed()
|
||||
const firstRequest = await params(target)
|
||||
const first = await host.rewind(caller, firstRequest)
|
||||
expect(first).toMatchObject({ ok: true })
|
||||
const secondRequest = await params(
|
||||
agentJournalItemKey({
|
||||
provider: 'codex',
|
||||
threadId: HOST_TEST_THREAD,
|
||||
turnId: 'kept',
|
||||
ordinal: 0
|
||||
})
|
||||
)
|
||||
recoverRewind.mockResolvedValueOnce({ ok: true, items: [] })
|
||||
expect(await host.rewind(caller, secondRequest)).toMatchObject({ ok: true })
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.operationId).toBe(
|
||||
secondRequest.envelope.clientOperationId
|
||||
)
|
||||
const effects = rewind.mock.calls.length
|
||||
const replay = await host.rewind(caller, firstRequest)
|
||||
expect(replay).toMatchObject({ ok: true, replayed: true })
|
||||
if (first.ok && replay.ok) {
|
||||
expect(replay.value).toEqual(first.value)
|
||||
}
|
||||
expect(rewind).toHaveBeenCalledTimes(effects)
|
||||
}
|
||||
|
||||
export async function expectRetainedPrefixRewindReplay({
|
||||
host,
|
||||
rewind,
|
||||
seed,
|
||||
params
|
||||
}: Pick<RewindReplayFixture, 'host' | 'rewind' | 'seed' | 'params'>) {
|
||||
const target = await seed()
|
||||
const request = await params(target)
|
||||
const result = await host.rewind(caller, request)
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1)
|
||||
expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe(
|
||||
request.expectedEpoch
|
||||
)
|
||||
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
|
||||
expect(rewind).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
expectSupersededRewindReplay,
|
||||
expectRetainedPrefixRewindReplay
|
||||
} from './structured-agent-session-rewind-replay.test-fixture'
|
||||
import { expectRawStopNoteRewindRecovery } from './structured-agent-session-rewind-stop-note.test-fixture'
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
@@ -190,6 +194,9 @@ async function params(itemId: string, epoch?: string) {
|
||||
const LATE = { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId: 'late', ordinal: 0 }
|
||||
|
||||
describe('host rewind', () => {
|
||||
it('replays the original ordinary rewind after another rewind replaces its record', () =>
|
||||
expectSupersededRewindReplay({ host, store, rewind, recoverRewind, seed, params }))
|
||||
|
||||
it('retains the raw Stop failure and recovers a committed rewind against raw bodies', async () => {
|
||||
await expectRawStopNoteRewindRecovery({ host, store, rewind })
|
||||
})
|
||||
@@ -266,18 +273,8 @@ describe('host rewind', () => {
|
||||
expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } })
|
||||
expect(rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('replaces the epoch with the retained prefix and replays without another provider call', async () => {
|
||||
const target = await seed()
|
||||
const request = await params(target)
|
||||
const result = await host.rewind(caller, request)
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1)
|
||||
expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe(
|
||||
request.expectedEpoch
|
||||
)
|
||||
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
|
||||
expect(rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('replaces the epoch with the retained prefix and replays without another provider call', () =>
|
||||
expectRetainedPrefixRewindReplay({ host, rewind, seed, params }))
|
||||
it('keeps when each kept message was first seen, not when the rewind re-read it', async () => {
|
||||
const target = await seed()
|
||||
const kept = (await host.journalSnapshot(HOST_TEST_SESSION)).items[0]!
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer'
|
||||
import {
|
||||
@@ -5,14 +6,13 @@ import {
|
||||
agentJournalSubmissionKey,
|
||||
parseAgentJournalItemKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
|
||||
import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer'
|
||||
import type {
|
||||
AgentSessionRewindParams,
|
||||
AgentSessionRewindRecord,
|
||||
AgentSessionRewindResult
|
||||
} from '../../../shared/agent-session-rewind'
|
||||
import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire'
|
||||
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
|
||||
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds'
|
||||
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
|
||||
import { openWithAgent } from './structured-agent-session-send-preparation'
|
||||
@@ -21,6 +21,7 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho
|
||||
import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission'
|
||||
import { conversationCommandBlocked } from './structured-conversation-command-admission'
|
||||
import { rewindRefusal } from './structured-rewind-refusal'
|
||||
import { renameRewindTurnOpener } from './structured-rewind-journal-body'
|
||||
import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery'
|
||||
import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows'
|
||||
|
||||
@@ -33,6 +34,7 @@ export async function rewindStructuredAgentSession(
|
||||
const { sessionId, clientOperationId } = params.envelope
|
||||
const store = context.deps.store
|
||||
return context.serialize(sessionId, async () => {
|
||||
let completionReceipt: JournalOperationReceipt | undefined
|
||||
const result = await admitAndRunAgentSessionMutation<AgentSessionRewindResult>({
|
||||
store,
|
||||
adapter: context.deps.adapter,
|
||||
@@ -41,7 +43,20 @@ export async function rewindStructuredAgentSession(
|
||||
callerKey: caller.callerKey,
|
||||
envelope: params.envelope,
|
||||
// Only the provider can do this, so an agent at rest is started first.
|
||||
prepareSession: openWithAgent(context, params.envelope),
|
||||
prepareSession: openWithAgent(context, params.envelope, () => {
|
||||
const journal = context.sessions.get(sessionId)?.journal
|
||||
const floor = journal?.context.floor()
|
||||
if (!journal || !floor) {
|
||||
return { ok: true }
|
||||
}
|
||||
if (journal.cursor().epoch !== params.expectedEpoch) {
|
||||
return rewindRefusal('stale-epoch')
|
||||
}
|
||||
const target = journal.snapshot().items.find((item) => item.itemId === params.itemId)
|
||||
return !target || target.sequence <= floor.sequence
|
||||
? rewindRefusal('invalid-target')
|
||||
: { ok: true }
|
||||
}),
|
||||
journal: () => context.sessions.get(sessionId)?.journal,
|
||||
publish: (journal) => context.publish(sessionId, journal),
|
||||
now: context.now,
|
||||
@@ -49,7 +64,16 @@ export async function rewindStructuredAgentSession(
|
||||
method: 'agentSession.rewind',
|
||||
fields: { itemId: params.itemId, expectedEpoch: params.expectedEpoch },
|
||||
recoverUnknownFromDurableState: true,
|
||||
settledOutcome: (rewind) => ({ status: 'succeeded', sessionId, rewind }),
|
||||
settledOutcome: (rewind: AgentSessionRewindResult) => ({
|
||||
status: 'succeeded' as const,
|
||||
sessionId,
|
||||
rewind
|
||||
}),
|
||||
settlesWithWrite: true,
|
||||
successReceipt: () => ({
|
||||
write: (db) => completionReceipt!.write(db),
|
||||
committed: () => completionReceipt!.committed()
|
||||
}),
|
||||
replay: (_ctx, outcome) => {
|
||||
if (outcome.status === 'succeeded' && outcome.rewind) {
|
||||
return outcome.rewind
|
||||
@@ -59,7 +83,11 @@ export async function rewindStructuredAgentSession(
|
||||
prior.callerKey === caller.callerKey &&
|
||||
prior.phase === 'completed' &&
|
||||
prior.epoch
|
||||
? { itemId: prior.itemId, epoch: prior.epoch }
|
||||
? {
|
||||
itemId: prior.itemId,
|
||||
epoch: prior.epoch,
|
||||
...(prior.sequence !== undefined ? { sequence: prior.sequence } : {})
|
||||
}
|
||||
: null
|
||||
},
|
||||
run: async (ctx) => {
|
||||
@@ -79,6 +107,10 @@ export async function rewindStructuredAgentSession(
|
||||
return rewindRefusal('busy')
|
||||
}
|
||||
const snapshot = ctx.journal.snapshot()
|
||||
const contextFloor = ctx.journal.context.floor()
|
||||
const contextStart = contextFloor
|
||||
? snapshot.items.findIndex((item) => item.sequence > contextFloor.sequence)
|
||||
: 0
|
||||
const providerKeys = new Map(
|
||||
snapshot.submissions.flatMap((submission) =>
|
||||
submission.dispatchState === 'accepted' && submission.providerItemId
|
||||
@@ -96,7 +128,11 @@ export async function rewindStructuredAgentSession(
|
||||
return rewindRefusal('stale-epoch')
|
||||
}
|
||||
const selected = snapshot.items.findIndex((item) => item.itemId === params.itemId)
|
||||
const key = selected === -1 ? null : parseAgentJournalItemKey(providerKey(params.itemId))
|
||||
const key =
|
||||
selected === -1 ||
|
||||
(contextFloor && snapshot.items[selected].sequence <= contextFloor.sequence)
|
||||
? null
|
||||
: parseAgentJournalItemKey(providerKey(params.itemId))
|
||||
// The head belongs to the record's provider: the record store refuses any other.
|
||||
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle
|
||||
if (!key || !head || key.provider !== record.provider) {
|
||||
@@ -108,6 +144,9 @@ export async function rewindStructuredAgentSession(
|
||||
return rewindRefusal('invalid-target')
|
||||
}
|
||||
boundary = snapshot.items.findIndex((item) => {
|
||||
if (contextFloor && item.sequence <= contextFloor.sequence) {
|
||||
return false
|
||||
}
|
||||
const identity = parseAgentJournalItemKey(providerKey(item.itemId))
|
||||
return (
|
||||
(identity?.provider === 'codex' &&
|
||||
@@ -120,7 +159,7 @@ export async function rewindStructuredAgentSession(
|
||||
return rewindRefusal('invalid-target')
|
||||
}
|
||||
const retained = snapshot.items
|
||||
.slice(0, boundary)
|
||||
.slice(Math.max(0, contextStart), boundary)
|
||||
.map(({ itemId, observedAt, turnScope, ...linkage }) => {
|
||||
const body = ctx.journal.itemBody(itemId)
|
||||
if (!body) {
|
||||
@@ -128,7 +167,7 @@ export async function rewindStructuredAgentSession(
|
||||
}
|
||||
return {
|
||||
itemId: providerKey(itemId),
|
||||
body: withRenamedTurnOpener(body, providerKey),
|
||||
body: renameRewindTurnOpener(body, providerKey),
|
||||
observedAt,
|
||||
...(turnScope ? { turnScope } : {}),
|
||||
...agentJournalLinkageFields(linkage)
|
||||
@@ -148,6 +187,12 @@ export async function rewindStructuredAgentSession(
|
||||
providerItemId: providerKey(params.itemId),
|
||||
expectedEpoch: params.expectedEpoch,
|
||||
phase: 'prepared',
|
||||
...(contextFloor && record.providerContextBoundary
|
||||
? {
|
||||
contextClearOperationId: record.providerContextBoundary.operationId,
|
||||
contextClearSequence: contextFloor.sequence
|
||||
}
|
||||
: {}),
|
||||
retained
|
||||
}
|
||||
await persistRewindRecord(store, sessionId, ctx.fence, prepared)
|
||||
@@ -204,8 +249,9 @@ export async function rewindStructuredAgentSession(
|
||||
)
|
||||
: prepared.retained
|
||||
if (
|
||||
confirmed.length > 10_000 ||
|
||||
Buffer.byteLength(JSON.stringify(confirmed), 'utf8') >
|
||||
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
|
||||
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
|
||||
) {
|
||||
throw new Error('agent_session_rewind:history-limit')
|
||||
}
|
||||
@@ -217,9 +263,32 @@ export async function rewindStructuredAgentSession(
|
||||
})
|
||||
const journal = context.sessions.get(sessionId)!.journal
|
||||
await attachContext.runtimeState.flushEventSink(sessionId)
|
||||
await recoverStructuredRewind(context.deps, sessionId, journal, fence)
|
||||
await recoverStructuredRewind(
|
||||
context.deps,
|
||||
sessionId,
|
||||
journal,
|
||||
fence,
|
||||
undefined,
|
||||
ctx.now,
|
||||
(rewind, currentFence, cursor) => {
|
||||
completionReceipt = store.conversationReceipts.rewind(
|
||||
sessionId,
|
||||
currentFence,
|
||||
rewind,
|
||||
cursor
|
||||
)
|
||||
return ctx.operationReceipt!
|
||||
}
|
||||
)
|
||||
context.publish(sessionId, journal)
|
||||
return { ok: true, value: { itemId: params.itemId, epoch: journal.cursor().epoch } }
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
itemId: params.itemId,
|
||||
epoch: journal.cursor().epoch,
|
||||
...(contextFloor ? { sequence: journal.cursor().sequence } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -232,18 +301,3 @@ export async function rewindStructuredAgentSession(
|
||||
: result
|
||||
})
|
||||
}
|
||||
|
||||
/** The new epoch keeps no submissions, so a sent message survives only under its provider key; the
|
||||
* turn it opened must name it by that key too, or the turn anchors on nothing. */
|
||||
function withRenamedTurnOpener(
|
||||
body: AgentJournalItemBody,
|
||||
rename: (itemId: string) => string
|
||||
): AgentJournalItemBody {
|
||||
if (body.kind === 'turn' && body.userItemId !== undefined) {
|
||||
return { ...body, userItemId: rename(body.userItemId) }
|
||||
}
|
||||
const lifecycle = body.kind === 'status' ? body.turnLifecycle : undefined
|
||||
return body.kind === 'status' && lifecycle?.userItemId !== undefined
|
||||
? { ...body, turnLifecycle: { ...lifecycle, userItemId: rename(lifecycle.userItemId) } }
|
||||
: body
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('a send refused by the conversation command it follows', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('says a committed /clear cleared the conversation', () => {
|
||||
it('allows a send to an independently retained old-build clear source', () => {
|
||||
const blocked = structuredAgentSessionSendBlock(
|
||||
withCommand({
|
||||
...COMMAND,
|
||||
@@ -40,10 +40,7 @@ describe('a send refused by the conversation command it follows', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(blocked?.refusal).toMatchObject({
|
||||
code: 'agent_session_operation_invalid',
|
||||
details: { reason: 'conversationCleared' }
|
||||
})
|
||||
expect(blocked).toBeNull()
|
||||
})
|
||||
|
||||
it("lets a send follow an older build's unconfirmed /compact, whose child this host no longer runs", () => {
|
||||
|
||||
+4
-6
@@ -199,7 +199,7 @@ describe('a send with no live owner', () => {
|
||||
expect(acquire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not restart an owner for a send the session refuses anyway', async () => {
|
||||
it('restarts an independently retained old-build clear source', async () => {
|
||||
await loseOwner()
|
||||
await store.transitionHandoff(SESSION, (current) => ({
|
||||
...current,
|
||||
@@ -213,12 +213,10 @@ describe('a send with no live owner', () => {
|
||||
}
|
||||
}))
|
||||
|
||||
await expect(host.send(CALLER, sendParams('into a cleared chat'))).resolves.toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_operation_invalid' }
|
||||
await expect(host.send(CALLER, sendParams('into the retained chat'))).resolves.toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
|
||||
expect(acquire).not.toHaveBeenCalled()
|
||||
await eventually(() => expect(acquire).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('restarts an owner that exited while the session stayed readable', async () => {
|
||||
|
||||
+13
-26
@@ -1,14 +1,13 @@
|
||||
// What a send or a Stop needs from the session before the ledger places its row: the
|
||||
// conversation open. Nothing here needs an owner — a send is accepted into the conversation and
|
||||
// the delivery loop makes the session ready — so a refusal before acceptance is only one the
|
||||
// conversation itself makes: a rewind in doubt, a cleared conversation, or a journal that cannot be
|
||||
// conversation itself makes: a rewind in doubt or a journal that cannot be
|
||||
// opened.
|
||||
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import {
|
||||
refuse,
|
||||
type AgentSessionMutationEnvelope,
|
||||
type AgentSessionWireRefusal
|
||||
import type {
|
||||
AgentSessionMutationEnvelope,
|
||||
AgentSessionWireRefusal
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names'
|
||||
import { isTuiAgent } from '../../../shared/tui-agent-config'
|
||||
@@ -43,23 +42,6 @@ export function structuredAgentSessionSendBlock(
|
||||
if (rewindInDoubt(record)) {
|
||||
return rewindRefusal('outcome-unknown')
|
||||
}
|
||||
const command = record?.conversationCommand
|
||||
// Only a committed clear: one that never committed changed nothing, and an older build's
|
||||
// unconfirmed record is one of those.
|
||||
if (
|
||||
command?.command === 'clear' &&
|
||||
command.phase === 'committed' &&
|
||||
command.replacementSessionId
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
refusal: refuse(
|
||||
'agent_session_operation_invalid',
|
||||
{ reason: 'conversationCleared' },
|
||||
'This conversation has been cleared. Use the current conversation.'
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -117,15 +99,20 @@ export function openForProviderWrite(
|
||||
/** For an operation only the provider can perform: the conversation, then its agent. */
|
||||
export function openWithAgent(
|
||||
context: Pick<StructuredAgentSessionMutationContext, 'openConversation' | 'ensureAgent' | 'deps'>,
|
||||
envelope: AgentSessionMutationEnvelope
|
||||
): () => Promise<AgentSessionMutationSessionPreparation> {
|
||||
return async () => {
|
||||
envelope: AgentSessionMutationEnvelope,
|
||||
beforeAgent?: () => AgentSessionMutationSessionPreparation
|
||||
): (ledger: 'admit' | 'replay') => Promise<AgentSessionMutationSessionPreparation> {
|
||||
return async (ledger) => {
|
||||
const opened = await openConversationForWrite(
|
||||
context.openConversation,
|
||||
envelope,
|
||||
context.deps.logger
|
||||
)
|
||||
return opened.ok ? context.ensureAgent(envelope.sessionId) : opened
|
||||
if (!opened.ok || ledger === 'replay') {
|
||||
return opened
|
||||
}
|
||||
const checked = beforeAgent?.()
|
||||
return checked && !checked.ok ? checked : context.ensureAgent(envelope.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* its Stop event's reason (`JournalStopEvent`); the others are in memory only. */
|
||||
export type StructuredAgentSessionChildEndCause =
|
||||
| 'user-stop'
|
||||
/** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */
|
||||
/** The user closed this chat or its launch. */
|
||||
| 'user-close'
|
||||
| 'context-clear'
|
||||
| 'host-stop'
|
||||
| 'exit'
|
||||
| 'attach-failed'
|
||||
@@ -13,5 +14,5 @@ export type StructuredAgentSessionChildEndCause =
|
||||
* is what a turn's end reads, so never rename an arm. */
|
||||
export type StructuredAgentSessionStopCause = Extract<
|
||||
StructuredAgentSessionChildEndCause,
|
||||
'user-stop' | 'user-close' | 'host-stop' | 'evict'
|
||||
'user-stop' | 'user-close' | 'context-clear' | 'host-stop' | 'evict'
|
||||
>
|
||||
|
||||
+10
-5
@@ -627,7 +627,7 @@ describe('what is not kept', () => {
|
||||
})
|
||||
|
||||
describe('/clear carries a kept card', () => {
|
||||
it('the first turn in the new conversation releases it, after that turn', async () => {
|
||||
it('the first turn after clear releases the same kept card after that turn', async () => {
|
||||
const id = await acceptWhileStarting(sendRequest('carried through clear'))
|
||||
await quitRestart()
|
||||
expect(await rig.drafts()).toHaveLength(1)
|
||||
@@ -636,9 +636,9 @@ describe('/clear carries a kept card', () => {
|
||||
envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()),
|
||||
...fields
|
||||
})
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
const replacementId = cleared.ok ? SESSION : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
throw new Error('expected clear to succeed')
|
||||
}
|
||||
expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }])
|
||||
// Nothing runs in the fresh conversation, so no paused row.
|
||||
@@ -649,7 +649,7 @@ describe('/clear carries a kept card', () => {
|
||||
envelope: {
|
||||
sessionId: replacementId,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: 1,
|
||||
expectedRuntimeFence: rig.store.getRecord(replacementId)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: replacementId,
|
||||
@@ -670,7 +670,12 @@ describe('/clear carries a kept card', () => {
|
||||
await rig.host.settleLateDispatch({
|
||||
sessionId: replacementId,
|
||||
clientMessageId: turnId,
|
||||
providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-x', ordinal: 0 }
|
||||
providerIdentity: {
|
||||
provider: 'codex',
|
||||
threadId: rig.store.getRecord(replacementId)!.providerHandleChain.at(-1)!.handle.nativeId,
|
||||
turnId: 'turn-x',
|
||||
ordinal: 0
|
||||
}
|
||||
})
|
||||
await eventually(() =>
|
||||
expect(dispatchedTexts()).toEqual(['first turn after the clear', 'carried through clear'])
|
||||
|
||||
+15
-10
@@ -1,3 +1,4 @@
|
||||
import { agentSessionCurrentContextRows } from '../../../shared/agent-session-context-clear'
|
||||
// Whether a session was genuinely working when this process stopped it, and what it was doing.
|
||||
//
|
||||
// Read off the LIVE host state, never off a persisted status field. That distinction is the whole
|
||||
@@ -75,11 +76,12 @@ export function structuredAgentSessionWorkInFlight(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
): AgentSessionResumeWork | null {
|
||||
const turnId = activeStructuredAgentSessionTurnId(items)
|
||||
const current = agentSessionCurrentContextRows(items, submissions)
|
||||
const turnId = activeStructuredAgentSessionTurnId(current.items)
|
||||
if (turnId) {
|
||||
return { kind: 'turn', id: turnId }
|
||||
}
|
||||
const submission = pendingSubmissionInFlight(submissions)
|
||||
const submission = pendingSubmissionInFlight(current.submissions)
|
||||
return submission ? { kind: 'submission', id: submission.clientMessageId } : null
|
||||
}
|
||||
|
||||
@@ -185,27 +187,27 @@ export function structuredAgentSessionWorkingAtStop(input: {
|
||||
return null
|
||||
}
|
||||
const snapshot = session.journal.snapshot()
|
||||
const current = agentSessionCurrentContextRows(snapshot.items, snapshot.submissions)
|
||||
// A queued message reached no agent, and one handed to a start that never answered ran nowhere:
|
||||
// neither is work to resume. The next open or the exit keeps it as unsent.
|
||||
const unanswered = input.startAnswered?.(sessionId) === false
|
||||
const handedOver = snapshot.submissions.filter(
|
||||
const handedOver = current.submissions.filter(
|
||||
(submission) =>
|
||||
!isQueuedAgentJournalSubmission(submission) &&
|
||||
!(unanswered && isUnansweredHandedOverSubmission(submission))
|
||||
)
|
||||
const childWork = input.childWork(sessionId)
|
||||
const status = structuredAgentSessionShownStatus(
|
||||
{ items: snapshot.items, submissions: handedOver },
|
||||
{ items: current.items, submissions: handedOver },
|
||||
childWork,
|
||||
session.child.fence
|
||||
)
|
||||
if (status.state === 'done') {
|
||||
return null
|
||||
}
|
||||
const work = structuredAgentSessionResumeWork(snapshot.items, handedOver)
|
||||
const head = agentSessionProviderHandleChainHead(
|
||||
input.getRecord(sessionId)?.providerHandleChain ?? []
|
||||
)
|
||||
const work = structuredAgentSessionResumeWork(current.items, handedOver)
|
||||
const record = input.getRecord(sessionId)
|
||||
const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null
|
||||
if (!work || !head) {
|
||||
return null
|
||||
}
|
||||
@@ -213,17 +215,20 @@ export function structuredAgentSessionWorkingAtStop(input: {
|
||||
// The lead's OWN state, not the fold: a settled lead with running children reads `done` here
|
||||
// and carries them in `tasks`, which is how the dialog tells the two apart.
|
||||
state: status.mainAgent.state,
|
||||
prompts: pendingPrompts(snapshot.items),
|
||||
prompts: pendingPrompts(current.items),
|
||||
tasks: liveTasks(childWork)
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
work,
|
||||
latestUserItemId: latestStructuredAgentSessionUserItem(snapshot.items)?.itemId ?? null,
|
||||
latestUserItemId: latestStructuredAgentSessionUserItem(current.items)?.itemId ?? null,
|
||||
recordedAt: input.now,
|
||||
trigger: input.trigger,
|
||||
teardownId: input.teardownId,
|
||||
journalCursor: snapshot.cursor,
|
||||
...(record?.providerContextBoundary
|
||||
? { contextClearOperationId: record.providerContextBoundary.operationId }
|
||||
: {}),
|
||||
// Root, not key: the close path advances Claude's leaf moments after this runs, and a key
|
||||
// comparison would then refuse the session forever.
|
||||
providerHandleRoot: agentSessionProviderHandleRoot(head.handle),
|
||||
|
||||
+2
-5
@@ -166,7 +166,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () =>
|
||||
expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses on a committed clear', () => {
|
||||
it('does not permanently refuse an old-build clear source', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record.
|
||||
const record = {
|
||||
lease: {},
|
||||
@@ -177,10 +177,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () =>
|
||||
replacementSessionId: 'clear-replacement'
|
||||
}
|
||||
} as unknown as AgentSessionRecord
|
||||
expect(conversationCommandBlocked(contextWith(undefined), record, [])).toMatchObject({
|
||||
code: 'agent_session_operation_invalid',
|
||||
details: { reason: 'conversationCleared' }
|
||||
})
|
||||
expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('at handover, lets the command itself and messages queued behind it wait', () => {
|
||||
|
||||
+10
-39
@@ -2,12 +2,12 @@ import {
|
||||
agentChildWorkViewOffersStop,
|
||||
type AgentSessionBackgroundTaskStops
|
||||
} from '../../../shared/agent-child-work-stop-targets'
|
||||
import type { AgentSessionConversationCommandRecord } from '../../../shared/agent-session-conversation-command'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
|
||||
import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness'
|
||||
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
|
||||
import { agentSessionCurrentContextRows } from '../../../shared/agent-session-context-clear'
|
||||
import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
@@ -35,26 +35,6 @@ function blocked(
|
||||
return refuse('agent_session_operation_invalid', { reason }, message)
|
||||
}
|
||||
|
||||
/**
|
||||
* The /clear this caller committed on a conversation whose tab has since moved to its replacement.
|
||||
* A /clear it presses there again asks for what that one already did; any other caller, or a
|
||||
* cleared conversation opened again from history, reads `conversationCleared` instead.
|
||||
*/
|
||||
export function committedClearOfCaller(
|
||||
record: AgentSessionRecord | null,
|
||||
callerKey: string,
|
||||
tabId: string | null
|
||||
): AgentSessionConversationCommandRecord | null {
|
||||
const command = record?.conversationCommand
|
||||
return command?.command === 'clear' &&
|
||||
command.phase === 'committed' &&
|
||||
command.replacementSessionId &&
|
||||
command.callerKey === callerKey &&
|
||||
tabId === null
|
||||
? command
|
||||
: null
|
||||
}
|
||||
|
||||
export function conversationCommandInFlight(): AgentSessionWireRefusal {
|
||||
return blocked('conversationCommandInFlight', 'Wait for the conversation operation to finish.')
|
||||
}
|
||||
@@ -74,20 +54,13 @@ export function conversationCommandBlocked(
|
||||
childWork: readonly AgentChildWorkView[] | undefined,
|
||||
admission?: 'at-rest' | 'handover'
|
||||
): AgentSessionWireRefusal | null {
|
||||
const items = ctx.journal.snapshot().items
|
||||
const { items, submissions } = agentSessionCurrentContextRows(
|
||||
ctx.journal.snapshot().items,
|
||||
ctx.journal.submissions()
|
||||
)
|
||||
if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') {
|
||||
return blocked('rewindUnconfirmed', 'agent_session_rewind:outcome-unknown')
|
||||
}
|
||||
if (
|
||||
record.conversationCommand?.command === 'clear' &&
|
||||
record.conversationCommand.phase === 'committed' &&
|
||||
record.conversationCommand.replacementSessionId
|
||||
) {
|
||||
return blocked(
|
||||
'conversationCleared',
|
||||
'This conversation has been cleared. Open the current conversation to continue.'
|
||||
)
|
||||
}
|
||||
if (record.lease.handoffStage || record.lease.handoffOperationId) {
|
||||
return blocked('handoffInFlight', 'Wait for the session handoff to finish.')
|
||||
}
|
||||
@@ -118,13 +91,11 @@ export function conversationCommandBlocked(
|
||||
// The Working indicator's own rule, so "unsettled" is exactly what the chat shows as working.
|
||||
// At handover the queued messages are those behind this command, waiting for it.
|
||||
if (
|
||||
ctx.journal
|
||||
.submissions()
|
||||
.some(
|
||||
(entry) =>
|
||||
!(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) &&
|
||||
isUnansweredStructuredAgentSessionDispatch(entry, ctx.fence)
|
||||
)
|
||||
submissions.some(
|
||||
(entry) =>
|
||||
!(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) &&
|
||||
isUnansweredStructuredAgentSessionDispatch(entry, ctx.fence)
|
||||
)
|
||||
) {
|
||||
return blocked(
|
||||
'messagesUnsettled',
|
||||
|
||||
+4
-47
@@ -10,7 +10,7 @@ import type { StructuredAgentSessionCaller } from './structured-agent-session-ho
|
||||
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
|
||||
export class StructuredConversationCommandController {
|
||||
/** Held only by a clear, which replaces the conversation a send would land in. A compaction is
|
||||
/** Held only while a clear resets the context a send would land in. A compaction is
|
||||
* a queued message, and sends accepted behind it wait for it in the queue. */
|
||||
readonly pending = new Map<string, { key: string; count: number }>()
|
||||
constructor(
|
||||
@@ -29,7 +29,9 @@ export class StructuredConversationCommandController {
|
||||
|
||||
run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => {
|
||||
if (params.command === 'compact') {
|
||||
return runStructuredCompaction(this.context(), this.host, caller, params)
|
||||
return runStructuredCompaction(this.context(), this.host, caller, params, {
|
||||
clearInFlight: this.pending.has(params.envelope.sessionId)
|
||||
})
|
||||
}
|
||||
const key = JSON.stringify([caller.callerKey, params.envelope.clientOperationId])
|
||||
const pending = this.pending.get(params.envelope.sessionId)
|
||||
@@ -48,49 +50,4 @@ export class StructuredConversationCommandController {
|
||||
this.context().wakeQueuedDrain?.(params.envelope.sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
replacements = () => {
|
||||
const store = this.context().deps.store
|
||||
const records = store.listRecords()
|
||||
const visible = new Set(store.listVisibleSessionIds())
|
||||
const byId = new Map(records.map((record) => [record.sessionId, record]))
|
||||
const destinations = new Map<string, string | null>()
|
||||
const destination = (source: string): string | null => {
|
||||
const path = new Set<string>()
|
||||
let current = source
|
||||
while (!destinations.has(current) && !path.has(current)) {
|
||||
path.add(current)
|
||||
const command = byId.get(current)?.conversationCommand
|
||||
if (
|
||||
command?.command !== 'clear' ||
|
||||
command.phase !== 'committed' ||
|
||||
!command.replacementSessionId
|
||||
) {
|
||||
destinations.set(current, current)
|
||||
break
|
||||
}
|
||||
current = command.replacementSessionId
|
||||
}
|
||||
const target = destinations.get(current) ?? null
|
||||
for (const id of path) {
|
||||
destinations.set(id, target)
|
||||
}
|
||||
return target
|
||||
}
|
||||
return records.flatMap((record) => {
|
||||
const target = destination(record.sessionId)
|
||||
const sessionId = target !== record.sessionId ? target : null
|
||||
// Explicit history reveals remain readable; closed replacements stay closed.
|
||||
return sessionId && visible.has(sessionId) && !visible.has(record.sessionId)
|
||||
? [
|
||||
{
|
||||
sourceSessionId: record.sessionId,
|
||||
sessionId,
|
||||
workspaceId: record.location.workspaceId,
|
||||
agent: record.provider
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { listStructuredProviderSessionOwnership } from './structured-provider-session-ownership'
|
||||
import { findConflictingStructuredAdoption } from '../structured-agent-session-history-adoption'
|
||||
import { cp, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -10,10 +11,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record
|
||||
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
|
||||
import {
|
||||
AgentSessionAcquisitionRefusal,
|
||||
type StructuredAgentSessionAdapter
|
||||
} from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep'
|
||||
import {
|
||||
HOST_TEST_NOW,
|
||||
@@ -153,595 +151,176 @@ afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Every record but the first is one a committed /clear points at: nothing is left orphaned. */
|
||||
function expectEveryReplacementPointedAt(): void {
|
||||
const records = store.listRecords()
|
||||
const pointedAt = new Set(
|
||||
records.map((record) => record.conversationCommand?.replacementSessionId)
|
||||
const journal = () => host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!.journal
|
||||
const clearRows = async () =>
|
||||
(await host.journalSnapshot(HOST_TEST_SESSION)).items.filter(
|
||||
(item) => item.body.kind === 'status' && item.body.contextClear
|
||||
)
|
||||
expect(
|
||||
records.filter(
|
||||
(record) =>
|
||||
record.sessionId !== HOST_TEST_SESSION &&
|
||||
!pointedAt.has(record.sessionId) &&
|
||||
!notFromAClear.has(record.sessionId)
|
||||
)
|
||||
).toEqual([])
|
||||
}
|
||||
const notFromAClear = new Set<string>()
|
||||
|
||||
afterEach(() => {
|
||||
expectEveryReplacementPointedAt()
|
||||
notFromAClear.clear()
|
||||
})
|
||||
|
||||
function startsFor(sessionId: string): number {
|
||||
return vi
|
||||
.mocked(adapter.acquire)
|
||||
.mock.calls.filter(([input]) => input.identity.sessionId === sessionId).length
|
||||
}
|
||||
|
||||
function sendTo(sessionId: string, text: string) {
|
||||
const body = hostTestMessage(text)
|
||||
return host.send(caller, {
|
||||
body,
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId,
|
||||
fields: { body }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function clearCommits(
|
||||
params = commandParams('clear'),
|
||||
who: { callerKey: string } = caller
|
||||
): Promise<string> {
|
||||
async function clearCommits(params = commandParams('clear'), who = caller) {
|
||||
const result = await host.conversationCommand(who, params)
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
command: 'clear',
|
||||
phase: 'committed',
|
||||
state: 'completed',
|
||||
replacementSessionId: expect.any(String)
|
||||
}
|
||||
})
|
||||
expect(result.ok && 'error' in result.value).toBe(false)
|
||||
return result.ok ? result.value.replacementSessionId! : ''
|
||||
expect(result).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } })
|
||||
if (!result.ok) {
|
||||
throw new Error('clear refused')
|
||||
}
|
||||
expect(result.value.replacementSessionId).toBeUndefined()
|
||||
expect(result.fence).toBe(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
|
||||
return result
|
||||
}
|
||||
|
||||
/** The idle sweep stops the source's agent, leaving the chat at rest. */
|
||||
async function sourceAtRest(): Promise<void> {
|
||||
clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1
|
||||
await host.collaboratorsForTests().lifetime.idleSweep.tick()
|
||||
expect(host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null).toBeNull()
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus).toBe('released')
|
||||
}
|
||||
|
||||
async function submissionOf(sessionId: string, clientMessageId: string) {
|
||||
return (await host.journalSnapshot(sessionId)).submissions.find(
|
||||
(entry) => entry.clientMessageId === clientMessageId
|
||||
)
|
||||
}
|
||||
|
||||
async function errorRows(sessionId: string): Promise<string[]> {
|
||||
return (await host.journalSnapshot(sessionId)).items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : []
|
||||
)
|
||||
}
|
||||
|
||||
describe('/clear starts nothing', () => {
|
||||
it('commits the marker and an at-rest replacement founded from the source, and starts no agent', async () => {
|
||||
await store.replaceSessionOptions({
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
fence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence,
|
||||
options: { model: 'test-model', effort: 'low' },
|
||||
now: HOST_TEST_NOW
|
||||
})
|
||||
describe('same-conversation clear', () => {
|
||||
it('keeps the record, journal, tab, options and earlier messages while starting nothing', async () => {
|
||||
const source = store.getRecord(HOST_TEST_SESSION)!
|
||||
const params = commandParams('clear')
|
||||
const replacement = await clearCommits(params)
|
||||
expect(adapter.acquire).toHaveBeenCalledTimes(1)
|
||||
expect(replacement).toMatch(/^clear-[0-9a-f]{40}$/)
|
||||
expect(store.getRecord(replacement)).toMatchObject({
|
||||
sessionId: replacement,
|
||||
location: source.location,
|
||||
provider: source.provider,
|
||||
accountHome: source.accountHome,
|
||||
options: { model: 'test-model', effort: 'low' },
|
||||
// Never started: nothing to resume, so its first start is a fresh conversation.
|
||||
providerHandleChain: [],
|
||||
lease: {
|
||||
runtimeFence: 1,
|
||||
claimStatus: 'released',
|
||||
handoffStage: null,
|
||||
ownerProcess: null,
|
||||
reservedSpawnToken: null,
|
||||
unreconciled: false,
|
||||
claimKeyId: 'key'
|
||||
const tab = store.getSessionTabId(HOST_TEST_SESSION)
|
||||
await journal().appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'earlier' },
|
||||
hostTestMessage('earlier'),
|
||||
{
|
||||
fence: source.lease.runtimeFence,
|
||||
turnScope: { kind: 'thread' }
|
||||
}
|
||||
})
|
||||
// Written at the fence the old agent's stop released the lease at.
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
runtimeFence: source.lease.runtimeFence + 1
|
||||
})
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toEqual({
|
||||
command: 'clear',
|
||||
phase: 'committed',
|
||||
state: 'completed',
|
||||
runtimeFence: source.lease.runtimeFence + 1,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
callerKey: caller.callerKey,
|
||||
replacementSessionId: replacement
|
||||
})
|
||||
expect(store.listVisibleSessionIds()).toEqual([replacement])
|
||||
expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement])
|
||||
expect(host.collaboratorsForTests().sessions.get(replacement)?.child ?? null).toBeNull()
|
||||
expect((await host.history({ sessionId: replacement, direction: 'tail' })).page.items).toEqual(
|
||||
[]
|
||||
)
|
||||
// The old conversation stays readable and takes no more messages.
|
||||
expect(await host.send(caller, sendParams('into the old chat'))).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { details: { reason: 'conversationCleared' } }
|
||||
})
|
||||
expect(adapter.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies the launch arguments the source was pinned to', async () => {
|
||||
const pinned = 'session-pinned'
|
||||
expect(
|
||||
await host.attach(
|
||||
caller,
|
||||
hostTestAttachParams(null, {
|
||||
envelope: {
|
||||
sessionId: pinned,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: ''
|
||||
},
|
||||
launchArgs: ['--flag']
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: true })
|
||||
const clear = { ...commandParams('clear') }
|
||||
clear.envelope = {
|
||||
sessionId: pinned,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: store.getRecord(pinned)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.conversationCommand',
|
||||
sessionId: pinned,
|
||||
fields: { command: 'clear' }
|
||||
})
|
||||
}
|
||||
notFromAClear.add(pinned)
|
||||
const replacement = await clearCommits(clear)
|
||||
expect(store.getRecord(replacement)?.launchArgs).toEqual(['--flag'])
|
||||
})
|
||||
|
||||
it("does not start an at-rest source's agent", async () => {
|
||||
await sourceAtRest()
|
||||
const starts = vi.mocked(adapter.acquire).mock.calls.length
|
||||
const replacement = await clearCommits()
|
||||
expect(adapter.acquire).toHaveBeenCalledTimes(starts)
|
||||
expect(startsFor(HOST_TEST_SESSION)).toBe(1)
|
||||
expect(startsFor(replacement)).toBe(0)
|
||||
})
|
||||
|
||||
it("stops a running source's agent before it writes the marker", async () => {
|
||||
const commit = store.commitConversationClear
|
||||
let atCommit: { child: unknown; claim: string | undefined } | undefined
|
||||
vi.spyOn(store, 'commitConversationClear').mockImplementationOnce(async (clear) => {
|
||||
atCommit = {
|
||||
child: host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null,
|
||||
claim: store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus
|
||||
}
|
||||
return commit(clear)
|
||||
})
|
||||
await clearCommits()
|
||||
expect(atCommit).toEqual({ child: null, claim: 'released' })
|
||||
})
|
||||
|
||||
// Its own cause, never the reason of whatever Stop the journal holds last.
|
||||
it("ends a running source's agent as the user closing the chat", async () => {
|
||||
const session = host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!
|
||||
await session.journal.appendStopEvent(
|
||||
{ reason: 'host-stop' },
|
||||
store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence
|
||||
)
|
||||
const commit = store.commitConversationClear
|
||||
let endedAs: string | undefined
|
||||
vi.spyOn(store, 'commitConversationClear').mockImplementationOnce(async (clear) => {
|
||||
endedAs = session.lastEndedChild?.cause
|
||||
return commit(clear)
|
||||
})
|
||||
|
||||
await clearCommits()
|
||||
|
||||
expect(endedAs).toBe('user-close')
|
||||
})
|
||||
|
||||
it('founds one record per /clear through a chain of clears, starting neither', async () => {
|
||||
const first = await clearCommits()
|
||||
const second = await host.conversationCommand(caller, {
|
||||
command: 'clear',
|
||||
envelope: {
|
||||
sessionId: first,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: store.getRecord(first)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.conversationCommand',
|
||||
sessionId: first,
|
||||
fields: { command: 'clear' }
|
||||
})
|
||||
}
|
||||
})
|
||||
expect(second).toMatchObject({ ok: true, value: { replacementSessionId: expect.any(String) } })
|
||||
const last = second.ok ? second.value.replacementSessionId! : ''
|
||||
expect(last).not.toBe(first)
|
||||
expect(store.listRecords().map((record) => record.sessionId)).toEqual(
|
||||
expect.arrayContaining([HOST_TEST_SESSION, first, last])
|
||||
)
|
||||
expect(store.listRecords()).toHaveLength(3)
|
||||
expect(startsFor(first) + startsFor(last)).toBe(0)
|
||||
expect(store.listVisibleSessionIds()).toEqual([last])
|
||||
expect(host.conversationReplacements()).toEqual([
|
||||
expect.objectContaining({ sourceSessionId: HOST_TEST_SESSION, sessionId: last }),
|
||||
expect.objectContaining({ sourceSessionId: first, sessionId: last })
|
||||
])
|
||||
})
|
||||
|
||||
it('writes nothing when its one write fails, leaving the source usable', async () => {
|
||||
adapter.rewindSupport = () => ({ supported: true })
|
||||
vi.spyOn(store, 'commitConversationClear').mockRejectedValueOnce(new Error('disk full'))
|
||||
await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow(
|
||||
'disk full'
|
||||
)
|
||||
expect(store.listRecords().map((record) => record.sessionId)).toEqual([HOST_TEST_SESSION])
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toBeUndefined()
|
||||
const before = await host.journalSnapshot(HOST_TEST_SESSION)
|
||||
const done = await clearCommits()
|
||||
const after = await host.journalSnapshot(HOST_TEST_SESSION)
|
||||
const cleared = store.getRecord(HOST_TEST_SESSION)!
|
||||
expect(store.listRecords()).toHaveLength(1)
|
||||
expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION])
|
||||
// Past every conversation check: only the stale epoch it names stops it.
|
||||
expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(tab)
|
||||
expect(cleared.providerHandleChain).toEqual([])
|
||||
const ownership = listStructuredProviderSessionOwnership(store.listRecords())
|
||||
expect(ownership).toEqual([])
|
||||
expect(
|
||||
await host.rewind(caller, {
|
||||
envelope: envelope('agentSession.rewind', {
|
||||
itemId: 'item-1',
|
||||
expectedEpoch: 'stale-epoch'
|
||||
}),
|
||||
itemId: 'item-1',
|
||||
expectedEpoch: 'stale-epoch'
|
||||
findConflictingStructuredAdoption({
|
||||
agent: 'codex',
|
||||
providerSessionId: source.providerHandleChain.at(-1)!.handle.nativeId,
|
||||
selfSessionId: 'another-conversation',
|
||||
ownership
|
||||
})
|
||||
).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } })
|
||||
expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({
|
||||
ok: true
|
||||
).toBeNull()
|
||||
expect(cleared.options).toEqual(source.options)
|
||||
expect(cleared.providerContextBoundary).toMatchObject({
|
||||
afterFence: done.fence,
|
||||
clearedAt: clock
|
||||
})
|
||||
expect(await host.send(caller, sendParams('still here'))).toMatchObject({ ok: true })
|
||||
expect(cleared.lease.claimStatus).toBe('released')
|
||||
expect(after.cursor.epoch).toBe(before.cursor.epoch)
|
||||
expect(after.items.slice(0, before.items.length)).toEqual(before.items)
|
||||
expect(await clearRows()).toHaveLength(1)
|
||||
expect(adapter.acquire).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.lastEndedChild?.cause
|
||||
).toBe('context-clear')
|
||||
})
|
||||
|
||||
it('reruns under the same operation id after its write failed, and commits once', async () => {
|
||||
vi.spyOn(store, 'commitConversationClear').mockRejectedValueOnce(new Error('disk full'))
|
||||
it('starts a fresh provider context on the first send using the clear answer fence', async () => {
|
||||
const previous = store.getRecord(HOST_TEST_SESSION)!.providerHandleChain.at(-1)!.handle.nativeId
|
||||
const done = await clearCommits()
|
||||
const params = sendParams('after clear')
|
||||
params.envelope.expectedRuntimeFence = done.fence
|
||||
expect(await host.send(caller, params)).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1))
|
||||
const last = vi.mocked(adapter.acquire).mock.calls.at(-1)![0]
|
||||
expect(last.identity.providerHandle).toBeNull()
|
||||
const head = store.getRecord(HOST_TEST_SESSION)!.providerHandleChain.at(-1)!
|
||||
expect(head.handle.nativeId).not.toBe(previous)
|
||||
expect(head).toMatchObject({ origin: 'created' })
|
||||
expect(head.replaces).toBeUndefined()
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.providerHandleChain).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('replays the same operation and makes a new boundary for a new operation', async () => {
|
||||
const params = commandParams('clear')
|
||||
await clearCommits(params)
|
||||
const first = store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary
|
||||
expect(await host.conversationCommand(caller, params)).toMatchObject({
|
||||
ok: true,
|
||||
replayed: true
|
||||
})
|
||||
expect(await clearRows()).toHaveLength(1)
|
||||
await clearCommits()
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).not.toEqual(first)
|
||||
expect(await clearRows()).toHaveLength(2)
|
||||
expect(store.listRecords()).toHaveLength(1)
|
||||
expect(adapter.acquire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('gives different callers distinct boundaries even when their operation IDs match', async () => {
|
||||
const params = commandParams('clear')
|
||||
await clearCommits(params)
|
||||
const first = store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary
|
||||
await clearCommits(params, { callerKey: 'mobile' })
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).not.toEqual(first)
|
||||
expect(await clearRows()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rolls back the divider, boundary and ledger success together when storage fails', async () => {
|
||||
const db = openTestJournalHostDatabase(generationRoot()).db
|
||||
db.exec(
|
||||
"CREATE TEMP TRIGGER fail_clear BEFORE UPDATE ON agent_session_records WHEN instr(NEW.record_json, 'providerContextBoundary') > 0 BEGIN SELECT RAISE(ABORT, 'disk full'); END"
|
||||
)
|
||||
const params = commandParams('clear')
|
||||
const before = await host.journalSnapshot(HOST_TEST_SESSION)
|
||||
await expect(host.conversationCommand(caller, params)).rejects.toThrow('disk full')
|
||||
const replacement = await clearCommits(params)
|
||||
expect(store.listRecords()).toHaveLength(2)
|
||||
expect(store.listVisibleSessionIds()).toEqual([replacement])
|
||||
expect(await clearRows()).toHaveLength(0)
|
||||
expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).toBe(before.cursor.epoch)
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).toBeUndefined()
|
||||
expect(
|
||||
store.getOperationRow(caller.callerKey, params.envelope.clientOperationId)?.outcome.status
|
||||
).toBe('pending')
|
||||
db.exec('DROP TRIGGER fail_clear')
|
||||
await clearCommits(params)
|
||||
expect(await clearRows()).toHaveLength(1)
|
||||
expect(
|
||||
store.getOperationRow(caller.callerKey, params.envelope.clientOperationId)?.outcome.status
|
||||
).toBe('succeeded')
|
||||
})
|
||||
|
||||
it('replays a same-id resend with the one replacement it committed', async () => {
|
||||
it('replays a lost acknowledgment after the atomic commit without another divider', async () => {
|
||||
const target = journal()
|
||||
const append = target.context.clear.bind(target.context)
|
||||
vi.spyOn(target.context, 'clear').mockImplementationOnce(async (...args) => {
|
||||
await append(...args)
|
||||
throw new Error('connection lost')
|
||||
})
|
||||
const params = commandParams('clear')
|
||||
const replacement = await clearCommits(params)
|
||||
await expect(host.conversationCommand(caller, params)).rejects.toThrow('connection lost')
|
||||
expect(
|
||||
store.getOperationRow(caller.callerKey, params.envelope.clientOperationId)?.outcome.status
|
||||
).toBe('succeeded')
|
||||
expect(await host.conversationCommand(caller, params)).toMatchObject({
|
||||
ok: true,
|
||||
replayed: true,
|
||||
value: { replacementSessionId: replacement }
|
||||
replayed: true
|
||||
})
|
||||
expect(store.listRecords()).toHaveLength(2)
|
||||
expect(await clearRows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reconstructs a committed replacement after the ledger settlement is lost', async () => {
|
||||
const persist = store.recordOperationOutcome.bind(store)
|
||||
vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => {
|
||||
if (input.outcome.status === 'succeeded' && input.outcome.conversationCommand) {
|
||||
throw new Error('crash')
|
||||
}
|
||||
return persist(input)
|
||||
})
|
||||
const params = commandParams('clear')
|
||||
await expect(host.conversationCommand(caller, params)).rejects.toThrow('crash')
|
||||
const [replacement] = store
|
||||
.listRecords()
|
||||
.flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId]))
|
||||
expect(await host.conversationCommand(caller, params)).toMatchObject({
|
||||
ok: true,
|
||||
replayed: true,
|
||||
value: { state: 'completed', replacementSessionId: replacement }
|
||||
})
|
||||
expect(store.listRecords()).toHaveLength(2)
|
||||
expect(acquisitions).toBe(1)
|
||||
it('does not complete when the previous process exit is unverifiable', async () => {
|
||||
vi.mocked(adapter.closeSession!).mockResolvedValueOnce(false)
|
||||
await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow()
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).toBeUndefined()
|
||||
expect(await clearRows()).toHaveLength(0)
|
||||
expect(adapter.acquire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("answers another window's /clear after the commit with the cleared refusal, never an in-flight one", async () => {
|
||||
it('starts fresh after a restart between clear and first send', async () => {
|
||||
await clearCommits()
|
||||
expect(
|
||||
await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear'))
|
||||
).toMatchObject({ ok: false, refusal: { details: { reason: 'conversationCleared' } } })
|
||||
expect(store.listRecords()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps explicitly revealed history and closed replacement tabs out of automatic restoration', async () => {
|
||||
const replacement = await clearCommits()
|
||||
expect(host.conversationReplacements()).toHaveLength(1)
|
||||
await host.setSessionTabVisibility(HOST_TEST_SESSION, true)
|
||||
expect(host.conversationReplacements()).toEqual([])
|
||||
await host.setSessionTabVisibility(HOST_TEST_SESSION, false)
|
||||
await host.setSessionTabVisibility(replacement, false)
|
||||
expect(host.conversationReplacements()).toEqual([])
|
||||
})
|
||||
|
||||
it('runs a command whose fence the client has not caught up to', async () => {
|
||||
const params = commandParams('compact')
|
||||
params.envelope.expectedRuntimeFence++
|
||||
expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
|
||||
describe("the replacement's first send", () => {
|
||||
it('starts a fresh conversation and delivers the message', async () => {
|
||||
const replacement = await clearCommits()
|
||||
const sent = await sendTo(replacement, 'first message')
|
||||
expect(sent).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1))
|
||||
expect(startsFor(replacement)).toBe(1)
|
||||
const [start] = vi
|
||||
.mocked(adapter.acquire)
|
||||
.mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : []))
|
||||
// No provider conversation to resume: the source's thread is not carried over.
|
||||
expect(start?.identity.providerHandle).toBeNull()
|
||||
expect(start?.fence).toBe(2)
|
||||
expect(store.getRecord(replacement)?.lease).toMatchObject({ claimStatus: 'live' })
|
||||
})
|
||||
|
||||
it('starts with the options the user chose, and adopts what the started child reports', async () => {
|
||||
await store.replaceSessionOptions({
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
fence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence,
|
||||
options: { model: 'test-model', effort: 'low' },
|
||||
now: HOST_TEST_NOW
|
||||
})
|
||||
adapter.readOptions = async () => ({
|
||||
models: [],
|
||||
current: { model: 'test-model', effort: 'low', fastMode: false }
|
||||
})
|
||||
const replacement = await clearCommits()
|
||||
expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(startsFor(replacement)).toBe(1))
|
||||
const [start] = vi
|
||||
.mocked(adapter.acquire)
|
||||
.mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : []))
|
||||
expect(start?.options).toEqual({ model: 'test-model', effort: 'low' })
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getRecord(replacement)?.options).toEqual({
|
||||
model: 'test-model',
|
||||
effort: 'low',
|
||||
fastMode: 'false'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('applies an option picked on the replacement before its first send', async () => {
|
||||
const replacement = await clearCommits()
|
||||
const picked = await host.setOption(caller, {
|
||||
envelope: {
|
||||
sessionId: replacement,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: store.getRecord(replacement)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.setOption',
|
||||
sessionId: replacement,
|
||||
fields: { key: 'model', value: 'picked-model' }
|
||||
})
|
||||
},
|
||||
key: 'model',
|
||||
value: 'picked-model'
|
||||
})
|
||||
expect(picked).toMatchObject({ ok: true })
|
||||
expect(startsFor(replacement)).toBe(0)
|
||||
expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(startsFor(replacement)).toBe(1))
|
||||
const [start] = vi
|
||||
.mocked(adapter.acquire)
|
||||
.mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : []))
|
||||
expect(start?.options).toMatchObject({ model: 'picked-model' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'not signed in',
|
||||
error: () => new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn'),
|
||||
words: "Codex isn't signed in. Run `codex login`.",
|
||||
kind: 'notSignedIn'
|
||||
},
|
||||
{
|
||||
name: 'a generic refusal',
|
||||
error: () => new Error('spawn codex ENOENT'),
|
||||
words: "Codex couldn't start.",
|
||||
kind: 'startFailed'
|
||||
}
|
||||
])(
|
||||
'fails on the message when the start fails ($name): one row, the message kept as not sent, and Retry starts it',
|
||||
async ({ error, words, kind }) => {
|
||||
const replacement = await clearCommits()
|
||||
vi.mocked(adapter.acquire).mockRejectedValueOnce(error())
|
||||
const sent = await sendTo(replacement, 'first message')
|
||||
expect(sent).toMatchObject({ ok: true })
|
||||
const clientMessageId = sent.ok ? sent.value.clientMessageId : ''
|
||||
await vi.waitFor(async () =>
|
||||
expect(await submissionOf(replacement, clientMessageId)).toMatchObject({
|
||||
dispatchState: 'rejected',
|
||||
rejection: { kind }
|
||||
})
|
||||
)
|
||||
const rows = await errorRows(replacement)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toContain(words)
|
||||
expect(rows.join(' ')).not.toContain('/clear')
|
||||
expect((await submissionOf(replacement, clientMessageId))?.reason).not.toContain('/clear')
|
||||
expect(adapter.dispatch).not.toHaveBeenCalled()
|
||||
|
||||
// Retry resends the same words, which starts the agent and delivers them.
|
||||
expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1))
|
||||
expect(startsFor(replacement)).toBe(2)
|
||||
}
|
||||
)
|
||||
|
||||
it('starts after a restart between the /clear and the first send', async () => {
|
||||
const replacement = await clearCommits()
|
||||
await restartHost()
|
||||
await host.restoreReadableSessions(store.listVisibleSessionIds())
|
||||
expect(store.getRecord(replacement)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
handoffStage: null,
|
||||
runtimeFence: 1
|
||||
})
|
||||
expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement])
|
||||
expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true })
|
||||
expect(await host.send(caller, sendParams('after restart'))).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1))
|
||||
expect(startsFor(replacement)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('what an older build left', () => {
|
||||
/** What an older build left when its clear's outcome was lost: gated every write until now. */
|
||||
async function restartOverAnOlderBuildsUnconfirmedClear() {
|
||||
const record = store.getRecord(HOST_TEST_SESSION)!
|
||||
await store.setConversationCommand(HOST_TEST_SESSION, record.lease.runtimeFence, {
|
||||
command: 'clear',
|
||||
runtimeFence: record.lease.runtimeFence,
|
||||
operationId: hostTestOperationId(),
|
||||
callerKey: caller.callerKey,
|
||||
phase: 'prepared',
|
||||
state: 'unknown',
|
||||
replacementSessionId: 'clear-from-an-older-build'
|
||||
})
|
||||
await restartHost()
|
||||
}
|
||||
|
||||
it("accepts a send on a restarted host holding an older build's unconfirmed clear", async () => {
|
||||
await restartOverAnOlderBuildsUnconfirmedClear()
|
||||
expect(await host.send(caller, sendParams('after the restart'))).toMatchObject({ ok: true })
|
||||
expect(vi.mocked(adapter.acquire).mock.calls.at(-1)![0].identity.providerHandle).toBeNull()
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.providerHandleChain).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("clears on a restarted host holding an older build's unconfirmed clear", async () => {
|
||||
await restartOverAnOlderBuildsUnconfirmedClear()
|
||||
it('does not start an idle-released provider to clear it', async () => {
|
||||
clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1
|
||||
await host.collaboratorsForTests().lifetime.idleSweep.tick()
|
||||
await clearCommits()
|
||||
expect(adapter.acquire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// The build before this one started the replacement before committing, so a crash between the
|
||||
// two left a started record nothing points at, and its start in the ledger.
|
||||
it('founds a fresh replacement beside the orphan an older build started for the same operation id', async () => {
|
||||
const params = commandParams('clear')
|
||||
const { clientOperationId } = params.envelope
|
||||
// The older build derived both ids from the operation, then crashed between start and commit.
|
||||
const digest = createHash('sha256')
|
||||
.update(JSON.stringify([HOST_TEST_SESSION, caller.callerKey, clientOperationId]))
|
||||
.digest('hex')
|
||||
const orphanStart = hostTestAttachParams(null, {
|
||||
envelope: {
|
||||
sessionId: `clear-${digest.slice(0, 40)}`,
|
||||
clientOperationId: `${HOST_TEST_NOW}-${digest.slice(0, 32)}`,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: ''
|
||||
}
|
||||
})
|
||||
const orphan = orphanStart.envelope.sessionId
|
||||
notFromAClear.add(orphan)
|
||||
expect(
|
||||
await store.admitMutationOperation({
|
||||
callerKey: caller.callerKey,
|
||||
envelope: params.envelope,
|
||||
hostFingerprint: params.envelope.payloadFingerprint,
|
||||
now: clock
|
||||
})
|
||||
).toMatchObject({ admission: { decision: 'admit' } })
|
||||
expect(await host.attach(caller, orphanStart)).toMatchObject({ ok: true })
|
||||
await restartHost()
|
||||
await host.restoreReadableSessions(store.listVisibleSessionIds())
|
||||
|
||||
const replacement = await clearCommits(params)
|
||||
expect(replacement).not.toBe(orphan)
|
||||
expect(store.listVisibleSessionIds()).toEqual([replacement])
|
||||
expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement])
|
||||
expect(host.collaboratorsForTests().sessions.has(orphan)).toBe(false)
|
||||
expect(startsFor(orphan)).toBe(1)
|
||||
expect(startsFor(replacement)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Every press carries its own operation id, so a /clear pressed after one committed is a new call.
|
||||
describe('a /clear pressed again after it committed', () => {
|
||||
function replacementsOtherThanTheSource(): string[] {
|
||||
return store
|
||||
.listRecords()
|
||||
.flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId]))
|
||||
}
|
||||
|
||||
function startsForSource(): number {
|
||||
return vi
|
||||
.mocked(adapter.acquire)
|
||||
.mock.calls.filter(([input]) => input.identity.sessionId === HOST_TEST_SESSION).length
|
||||
}
|
||||
|
||||
async function expectAnsweredWithTheCommittedClear(replacement: string): Promise<void> {
|
||||
const starts = startsForSource()
|
||||
const result = await host.conversationCommand(caller, commandParams('clear'))
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { phase: 'committed', state: 'completed', replacementSessionId: replacement }
|
||||
})
|
||||
expect(result.ok && result.value.error).toBeFalsy()
|
||||
expect(replacementsOtherThanTheSource()).toEqual([replacement])
|
||||
// The cleared conversation's agent is not started to answer it.
|
||||
expect(startsForSource()).toBe(starts)
|
||||
expect(store.listVisibleSessionIds()).toEqual([replacement])
|
||||
expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement])
|
||||
}
|
||||
|
||||
it('answers a double press with the clear the first press committed', async () => {
|
||||
const first = await host.conversationCommand(caller, commandParams('clear'))
|
||||
const replacement = first.ok ? first.value.replacementSessionId! : ''
|
||||
expect(replacement).toBeTruthy()
|
||||
// What the RPC handler does once the first answer is out.
|
||||
await host.close(HOST_TEST_SESSION, 'user-close')
|
||||
await expectAnsweredWithTheCommittedClear(replacement)
|
||||
})
|
||||
|
||||
it('answers a retyped /clear whose committed answer was lost with that clear', async () => {
|
||||
const persist = store.recordOperationOutcome.bind(store)
|
||||
let lost = false
|
||||
vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => {
|
||||
if (!lost && input.outcome.status === 'succeeded' && input.outcome.conversationCommand) {
|
||||
lost = true
|
||||
throw new Error('connection lost')
|
||||
}
|
||||
return persist(input)
|
||||
})
|
||||
await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow(
|
||||
'connection lost'
|
||||
)
|
||||
const [replacement] = replacementsOtherThanTheSource()
|
||||
await expectAnsweredWithTheCommittedClear(replacement!)
|
||||
})
|
||||
|
||||
it('refuses a second /clear pressed under a new id while the first is still running', async () => {
|
||||
it('refuses another clear while the first is in flight', async () => {
|
||||
const [first, second] = await Promise.all([
|
||||
host.conversationCommand(caller, commandParams('clear')),
|
||||
host.conversationCommand(caller, commandParams('clear'))
|
||||
@@ -751,25 +330,21 @@ describe('a /clear pressed again after it committed', () => {
|
||||
ok: false,
|
||||
refusal: { details: { reason: 'conversationCommandInFlight' } }
|
||||
})
|
||||
expect(replacementsOtherThanTheSource()).toHaveLength(1)
|
||||
expect(await clearRows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still tells another window the conversation was cleared', async () => {
|
||||
expect((await host.conversationCommand(caller, commandParams('clear'))).ok).toBe(true)
|
||||
expect(
|
||||
await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear'))
|
||||
).toMatchObject({ ok: false, refusal: { details: { reason: 'conversationCleared' } } })
|
||||
expect(replacementsOtherThanTheSource()).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Opened again from history on purpose, so that tab stays on the cleared conversation.
|
||||
it('still tells a cleared conversation opened from history that it was cleared', async () => {
|
||||
expect((await host.conversationCommand(caller, commandParams('clear'))).ok).toBe(true)
|
||||
await host.setSessionTabVisibility(HOST_TEST_SESSION, true)
|
||||
expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { details: { reason: 'conversationCleared' } }
|
||||
it('allows independently sending a record with an old-build replacement pointer', async () => {
|
||||
const source = store.getRecord(HOST_TEST_SESSION)!
|
||||
await store.setConversationCommand(HOST_TEST_SESSION, source.lease.runtimeFence, {
|
||||
command: 'clear',
|
||||
phase: 'committed',
|
||||
state: 'completed',
|
||||
operationId: hostTestOperationId(),
|
||||
callerKey: caller.callerKey,
|
||||
replacementSessionId: 'old-clear-destination'
|
||||
})
|
||||
expect(await host.send(caller, sendParams('still this conversation'))).toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
expect(replacementsOtherThanTheSource()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type {
|
||||
AgentSessionConversationCommand,
|
||||
AgentSessionConversationCommandResult
|
||||
@@ -8,21 +7,20 @@ import type {
|
||||
AgentSessionMutationResult
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission'
|
||||
import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger'
|
||||
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
|
||||
import { sendPreparation } from './structured-agent-session-send-preparation'
|
||||
import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
|
||||
import {
|
||||
committedClearOfCaller,
|
||||
conversationCommandBlocked
|
||||
} from './structured-conversation-command-admission'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
import { conversationCommandBlocked } from './structured-conversation-command-admission'
|
||||
import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import {
|
||||
agentSessionFailureWords,
|
||||
type AgentSessionFailureWordsContext
|
||||
} from '../../../shared/agent-session-failure-words'
|
||||
import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations'
|
||||
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
|
||||
import {
|
||||
providerContextBoundaryForClear,
|
||||
type AgentSessionConversationClear
|
||||
} from '../../runtime/agent-session-conversation-command-record'
|
||||
|
||||
/** A command's `error` is the sentence its row shows. */
|
||||
export function conversationCommandFailure(
|
||||
@@ -44,50 +42,7 @@ export type ConversationCommandParams = {
|
||||
/** Host-local, set by the client-facing command RPC as for an ordinary send. */
|
||||
userSend?: true
|
||||
}
|
||||
export type ConversationReplacement = {
|
||||
sourceSessionId: string
|
||||
sessionId: string
|
||||
workspaceId: string
|
||||
agent: StructuredAgentId
|
||||
}
|
||||
|
||||
const clearFingerprintOf = (sessionId: string) =>
|
||||
computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.conversationCommand',
|
||||
sessionId,
|
||||
fields: { command: 'clear' }
|
||||
})
|
||||
|
||||
/** This caller's committed /clear, for a /clear it presses again on the conversation that one
|
||||
* cleared. Answered before admission, which would refuse it as cleared. */
|
||||
async function answerFromCommittedClear(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
caller: StructuredAgentSessionCaller,
|
||||
{ envelope, command }: ConversationCommandParams
|
||||
): Promise<AgentSessionMutationResult<AgentSessionConversationCommandResult> | null> {
|
||||
const { store } = context.deps
|
||||
const record = store.getRecord(envelope.sessionId)
|
||||
const committed =
|
||||
command === 'clear' && envelope.payloadFingerprint === clearFingerprintOf(envelope.sessionId)
|
||||
? committedClearOfCaller(record, caller.callerKey, store.getSessionTabId(envelope.sessionId))
|
||||
: null
|
||||
const session =
|
||||
committed && (await context.openConversation(envelope.sessionId).catch(() => null))
|
||||
return record && committed && session
|
||||
? {
|
||||
ok: true,
|
||||
replayed: true,
|
||||
fence: record.lease.runtimeFence,
|
||||
cursor: session.journal.cursor(),
|
||||
value: committed
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* `/clear`: one write that points this conversation at a new, at-rest one and moves its tab there.
|
||||
* The new conversation's first send starts its agent.
|
||||
*/
|
||||
/** Stop the provider and record a fresh-context boundary in the same conversation. */
|
||||
export function runStructuredConversationCommand(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
caller: StructuredAgentSessionCaller,
|
||||
@@ -103,11 +58,8 @@ export function runStructuredConversationCommand(
|
||||
: null
|
||||
}
|
||||
return context.serialize(sessionId, async () => {
|
||||
const committed = await answerFromCommittedClear(context, caller, params)
|
||||
if (committed) {
|
||||
return committed
|
||||
}
|
||||
return admitAndRunAgentSessionMutation({
|
||||
let clear: AgentSessionConversationClear | null = null
|
||||
const result = await admitAndRunAgentSessionMutation({
|
||||
store,
|
||||
adapter: context.deps.adapter,
|
||||
agents: context.deps.agents,
|
||||
@@ -125,7 +77,17 @@ export function runStructuredConversationCommand(
|
||||
// Written to the conversation, not the agent, so whoever owns the agent does not matter.
|
||||
conversationWrite: true,
|
||||
recoverUnknownFromDurableState: true,
|
||||
settledOutcome: (value) => ({ status: 'succeeded', sessionId, conversationCommand: value }),
|
||||
settlesWithWrite: true,
|
||||
successReceipt: () =>
|
||||
store.conversationReceipts.clear(
|
||||
() => {
|
||||
if (!clear) {
|
||||
throw new Error('agent_session_clear_not_completed')
|
||||
}
|
||||
return clear
|
||||
},
|
||||
{ callerKey: caller.callerKey, operationId: clientOperationId }
|
||||
),
|
||||
replay: (_ctx, outcome) => {
|
||||
if (outcome.status === 'succeeded' && outcome.conversationCommand) {
|
||||
return outcome.conversationCommand
|
||||
@@ -146,42 +108,43 @@ export function runStructuredConversationCommand(
|
||||
if (blocked) {
|
||||
return { ok: false, refusal: blocked }
|
||||
}
|
||||
// Stopped before the marker, so nothing the old agent does can land after the clear. The
|
||||
// stop releases the lease, which moves its fence: the marker is written at the new one.
|
||||
// A /clear replaces this chat: the user closing it.
|
||||
await context.stopAgent(sessionId, { cause: 'user-close' })
|
||||
const fence = store.getRecord(sessionId)!.lease.runtimeFence
|
||||
const completed = {
|
||||
command,
|
||||
await context.stopAgent(sessionId, { cause: 'context-clear' })
|
||||
const stopped = store.getRecord(sessionId)!
|
||||
const fence = stopped.lease.runtimeFence
|
||||
const rechecked = conversationCommandBlocked(
|
||||
{ ...ctx, fence },
|
||||
stopped,
|
||||
context.readChildWork(sessionId),
|
||||
'at-rest'
|
||||
)
|
||||
if (rechecked) {
|
||||
return { ok: false, refusal: rechecked }
|
||||
}
|
||||
const completed: AgentSessionConversationClear['command'] = {
|
||||
command: 'clear',
|
||||
runtimeFence: fence,
|
||||
operationId: clientOperationId,
|
||||
callerKey: caller.callerKey,
|
||||
// Only has to be new: the marker is what points at it, and a same-id resend replays it.
|
||||
replacementSessionId: `clear-${randomBytes(20).toString('hex')}`,
|
||||
phase: 'committed' as const,
|
||||
state: 'completed' as const
|
||||
phase: 'committed',
|
||||
state: 'completed'
|
||||
}
|
||||
await store.commitConversationClear({
|
||||
sessionId,
|
||||
fence,
|
||||
command: completed,
|
||||
claimKeyId: context.deps.claimKeyId,
|
||||
now: context.now()
|
||||
})
|
||||
// Carry the source's drafts to the replacement, the same for every client version:
|
||||
// the cards stay visible where the user now is, and no text rides the wire.
|
||||
// Bookkeeping — a failure is reported and never fails the clear.
|
||||
await carryQueuedMessagesToClearReplacement(ctx, {
|
||||
replacementSessionId: completed.replacementSessionId,
|
||||
// Opened under its own lock, as every open is.
|
||||
openReplacementJournal: async () =>
|
||||
(await context.conversation(completed.replacementSessionId)).journal,
|
||||
callerKey: caller.callerKey,
|
||||
operationId: clientOperationId
|
||||
})
|
||||
clear = { sessionId, fence, command: completed, now: context.now() }
|
||||
if (!ctx.operationReceipt) {
|
||||
throw new Error('agent_session_clear_receipt_missing')
|
||||
}
|
||||
await ctx.journal.context.clear(
|
||||
providerContextBoundaryForClear(clear),
|
||||
ctx.operationReceipt,
|
||||
agentSessionOperationKey(caller.callerKey, clientOperationId)
|
||||
)
|
||||
return { ok: true, value: completed }
|
||||
}
|
||||
}
|
||||
})
|
||||
return result.ok &&
|
||||
'runtimeFence' in result.value &&
|
||||
typeof result.value.runtimeFence === 'number'
|
||||
? { ...result, fence: result.value.runtimeFence }
|
||||
: result
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ export async function runStructuredCompaction(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
host: Pick<StructuredAgentSessionHost, 'waitForSendSettlement'>,
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: ConversationCommandParams
|
||||
params: ConversationCommandParams,
|
||||
arrival: { clearInFlight?: boolean } = {}
|
||||
): Promise<AgentSessionMutationResult<AgentSessionConversationCommandResult>> {
|
||||
const { sessionId, clientOperationId } = params.envelope
|
||||
// An older build ran this operation id and recorded it on the session: answered, never rerun.
|
||||
@@ -61,7 +62,13 @@ export async function runStructuredCompaction(
|
||||
...conversationCommandFailure(agentSessionFailureFact('compactionUnconfirmed'))
|
||||
}
|
||||
}
|
||||
const accepted = await acceptStructuredConversationCommand(context, caller, params, priorRecord)
|
||||
const accepted = await acceptStructuredConversationCommand(
|
||||
context,
|
||||
caller,
|
||||
params,
|
||||
priorRecord,
|
||||
arrival
|
||||
)
|
||||
if (!accepted.ok) {
|
||||
return accepted
|
||||
}
|
||||
@@ -131,7 +138,8 @@ function acceptStructuredConversationCommand(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: ConversationCommandParams,
|
||||
priorRecord: () => AgentSessionConversationCommandResult | null
|
||||
priorRecord: () => AgentSessionConversationCommandResult | null,
|
||||
arrival: { clearInFlight?: boolean }
|
||||
): Promise<AgentSessionMutationResult<ConversationCommandAcceptance>> {
|
||||
const plan = conversationCommandPlan({
|
||||
envelope: params.envelope,
|
||||
@@ -189,6 +197,6 @@ function acceptStructuredConversationCommand(
|
||||
return accepted
|
||||
}
|
||||
},
|
||||
sendPreparation(context, params.envelope)
|
||||
sendPreparation(context, params.envelope, arrival)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { StructuredConversationCommandController } from './structured-conversation-command-controller'
|
||||
|
||||
function replacements(records: AgentSessionRecord[], visible: string[]) {
|
||||
const store = {
|
||||
listRecords: () => records,
|
||||
listVisibleSessionIds: () => visible,
|
||||
getRecord: (id: string) => records.find((record) => record.sessionId === id)
|
||||
}
|
||||
const controller = new StructuredConversationCommandController(
|
||||
() => ({ deps: { store } }) as never,
|
||||
{} as never
|
||||
)
|
||||
return controller.replacements()
|
||||
}
|
||||
|
||||
function record(id: string, next?: string): AgentSessionRecord {
|
||||
return {
|
||||
sessionId: id,
|
||||
provider: 'codex',
|
||||
location: { workspaceId: 'folder' },
|
||||
conversationCommand: next
|
||||
? { command: 'clear', phase: 'committed', replacementSessionId: next }
|
||||
: undefined
|
||||
} as AgentSessionRecord
|
||||
}
|
||||
|
||||
describe('conversation replacement projection', () => {
|
||||
it('visits a long clear chain only once per snapshot', () => {
|
||||
const reads = vi.fn()
|
||||
const records = Array.from({ length: 200 }, (_, index) => {
|
||||
const entry = record(String(index), index < 199 ? String(index + 1) : undefined)
|
||||
const command = entry.conversationCommand
|
||||
Object.defineProperty(entry, 'conversationCommand', {
|
||||
get: () => {
|
||||
reads()
|
||||
return command
|
||||
}
|
||||
})
|
||||
return entry
|
||||
})
|
||||
const result = replacements(records, ['199'])
|
||||
expect(result).toHaveLength(199)
|
||||
expect(result.every((entry) => entry.sessionId === '199')).toBe(true)
|
||||
expect(reads.mock.calls.length).toBeLessThanOrEqual(records.length * 2)
|
||||
})
|
||||
|
||||
it('keeps revealed history and closed chains out, and ignores cycles', () => {
|
||||
const records = [
|
||||
record('a', 'b'),
|
||||
record('b', 'c'),
|
||||
record('c'),
|
||||
record('x', 'y'),
|
||||
record('y', 'x')
|
||||
]
|
||||
expect(replacements(records, ['b', 'c', 'x'])).toEqual([
|
||||
{ sourceSessionId: 'a', sessionId: 'c', workspaceId: 'folder', agent: 'codex' }
|
||||
])
|
||||
expect(replacements(records, [])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -115,3 +115,16 @@ function withKnownTurnOutcome(
|
||||
const { outcome: _outcome, ...rest } = lifecycle
|
||||
return { ...body, turnLifecycle: rest }
|
||||
}
|
||||
|
||||
export function renameRewindTurnOpener(
|
||||
body: AgentJournalItemBody,
|
||||
rename: (itemId: string) => string
|
||||
): AgentJournalItemBody {
|
||||
if (body.kind === 'turn' && body.userItemId !== undefined) {
|
||||
return { ...body, userItemId: rename(body.userItemId) }
|
||||
}
|
||||
const lifecycle = body.kind === 'status' ? body.turnLifecycle : undefined
|
||||
return body.kind === 'status' && lifecycle?.userItemId !== undefined
|
||||
? { ...body, turnLifecycle: { ...lifecycle, userItemId: rename(lifecycle.userItemId) } }
|
||||
: body
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a
|
||||
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds'
|
||||
import { rewindRefusal } from './structured-rewind-refusal'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer'
|
||||
import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types'
|
||||
|
||||
type RewindRecoveryDeps = { store: AgentSessionRecordStore; logger: StructuredAgentSessionLogger }
|
||||
|
||||
@@ -71,7 +73,12 @@ export async function recoverStructuredRewind(
|
||||
journal: AgentSessionJournal,
|
||||
fence: number,
|
||||
adapter?: StructuredAgentSessionAdapter,
|
||||
now: () => number = Date.now
|
||||
now: () => number = Date.now,
|
||||
receipt?: (
|
||||
rewind: AgentSessionRewindRecord,
|
||||
fence: number,
|
||||
cursor: AgentJournalCursor
|
||||
) => JournalOperationReceipt
|
||||
): Promise<void> {
|
||||
const { store } = deps
|
||||
let rewind = store.getRecord(sessionId)?.rewind
|
||||
@@ -147,6 +154,26 @@ export async function recoverStructuredRewind(
|
||||
return
|
||||
}
|
||||
const replacement = rewind.retained.map(retainedRowReplacement)
|
||||
if (rewind.contextClearOperationId && rewind.contextClearSequence) {
|
||||
if (
|
||||
store.getRecord(sessionId)?.providerContextBoundary?.operationId !==
|
||||
rewind.contextClearOperationId ||
|
||||
journal.cursor().epoch !== rewind.expectedEpoch ||
|
||||
journal.context.floor()?.sequence !== rewind.contextClearSequence
|
||||
) {
|
||||
throw new Error('agent_session_rewind:stale-context')
|
||||
}
|
||||
const completed = rewind
|
||||
await journal.context.rewind(
|
||||
{ epoch: completed.expectedEpoch, sequence: completed.contextClearSequence! },
|
||||
fence,
|
||||
replacement,
|
||||
(cursor) =>
|
||||
receipt?.(completed, fence, cursor) ??
|
||||
store.conversationReceipts.rewind(sessionId, fence, completed, cursor)
|
||||
)
|
||||
return
|
||||
}
|
||||
// A crash after the journal transaction must settle its existing epoch, not replace it twice.
|
||||
const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch
|
||||
if (
|
||||
|
||||
@@ -117,6 +117,26 @@ describe('Pi host launch resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a fresh session after clear without reading the previous file', async () => {
|
||||
const h = await setup()
|
||||
const old = join(root, 'old-malformed.jsonl')
|
||||
await writeFile(old, 'not a Pi session header')
|
||||
vi.spyOn(h.store, 'getRecord').mockReturnValue({
|
||||
...h.record,
|
||||
providerHandleChain: [],
|
||||
providerContextBoundary: { operationId: 'clear', afterFence: 2, clearedAt: 100 }
|
||||
})
|
||||
const launch = await h.resolver({ ...identity, providerHandle: h.prior(old).handle })
|
||||
expect(launch.previous).toBeNull()
|
||||
expect(launch.sessionFile).toBeUndefined()
|
||||
expect(launch.forkFile).toBeUndefined()
|
||||
expect(launch.replacement).toBeUndefined()
|
||||
expect(buildPiRpcLaunch(launch).args).toEqual(['--mode', 'rpc'])
|
||||
expect(piRpcProviderLink(launch, join(root, 'fresh.jsonl'), 3, 'fresh', 101).origin).toBe(
|
||||
'created'
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns the binary the Command setting names, and refuses one that is not runnable', async () => {
|
||||
const h = await setup()
|
||||
h.settings.agentCmdOverrides = { pi: `"${process.execPath}"` }
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
// A /clear's one store write: the conversation it continues in, founded at rest, and the marker
|
||||
// on the cleared record that points there. Either both land, on disk too, or neither does.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
|
||||
import { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
|
||||
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import {
|
||||
providerContextBoundaryForClear,
|
||||
type AgentSessionConversationClear
|
||||
} from './agent-session-conversation-command-record'
|
||||
import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
|
||||
import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger'
|
||||
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
import type { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const SOURCE = 'session-alpha'
|
||||
const REPLACEMENT = `clear-${'a'.repeat(40)}`
|
||||
const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' }
|
||||
|
||||
let directory: string
|
||||
@@ -53,110 +57,157 @@ async function storeWithSource(): Promise<AgentSessionRecordStore> {
|
||||
},
|
||||
now: NOW
|
||||
})
|
||||
const fence = store.getRecord(SOURCE)!.lease.runtimeFence
|
||||
await store.commitProcessIdentity({
|
||||
sessionId: SOURCE,
|
||||
fence,
|
||||
process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' },
|
||||
now: NOW
|
||||
})
|
||||
await store.proveOwner({
|
||||
sessionId: SOURCE,
|
||||
fence,
|
||||
link: {
|
||||
linkId: 'pre-clear-proof',
|
||||
origin: 'created',
|
||||
mintedAtFence: fence,
|
||||
observedAt: NOW,
|
||||
handle: claudeProviderHandle('pre-clear-provider-context', null)
|
||||
},
|
||||
now: NOW
|
||||
})
|
||||
await store.setSessionTabVisibility(SOURCE, true, 'tab-alpha')
|
||||
return store
|
||||
}
|
||||
|
||||
function marker(fence: number, replacementSessionId = REPLACEMENT) {
|
||||
function clear(fence: number): AgentSessionConversationClear {
|
||||
return {
|
||||
command: 'clear' as const,
|
||||
state: 'completed' as const,
|
||||
phase: 'committed' as const,
|
||||
runtimeFence: fence,
|
||||
operationId: `${NOW}-${'2'.repeat(32)}`,
|
||||
callerKey: 'client-1',
|
||||
replacementSessionId
|
||||
sessionId: SOURCE,
|
||||
fence,
|
||||
now: NOW + 5,
|
||||
command: {
|
||||
command: 'clear',
|
||||
state: 'completed',
|
||||
phase: 'committed',
|
||||
runtimeFence: fence,
|
||||
operationId: `${NOW}-${'2'.repeat(32)}`,
|
||||
callerKey: 'client-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("a /clear's commit", () => {
|
||||
it('founds the replacement at rest and points the cleared record at it, on disk', async () => {
|
||||
const store = await storeWithSource()
|
||||
const fence = store.getRecord(SOURCE)!.lease.runtimeFence
|
||||
await store.commitConversationClear({
|
||||
async function prepare(released = true) {
|
||||
const store = await storeWithSource()
|
||||
if (released) {
|
||||
await store.evictProvenDeadOwner({
|
||||
sessionId: SOURCE,
|
||||
fence,
|
||||
command: marker(fence),
|
||||
claimKeyId: 'key-1',
|
||||
now: NOW + 5
|
||||
expectedFence: store.getRecord(SOURCE)!.lease.runtimeFence,
|
||||
probe: { outcome: 'exit-observed' },
|
||||
now: NOW + 1
|
||||
})
|
||||
}
|
||||
const journal = new AgentSessionJournal({
|
||||
database: openTestJournalHostDatabase(directory),
|
||||
identity: {
|
||||
sessionId: SOURCE,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'claude',
|
||||
providerHandle: null
|
||||
},
|
||||
now: () => NOW + 5
|
||||
})
|
||||
await journal.open()
|
||||
const completed = clear(store.getRecord(SOURCE)!.lease.runtimeFence)
|
||||
const operation = {
|
||||
callerKey: completed.command.callerKey,
|
||||
operationId: completed.command.operationId
|
||||
}
|
||||
await store.admitOperation({ ...operation, fingerprint: 'clear', now: NOW })
|
||||
const append = (input = completed) =>
|
||||
journal.context.clear(
|
||||
providerContextBoundaryForClear(input),
|
||||
store.conversationReceipts.clear(() => input, operation),
|
||||
agentSessionOperationKey(operation.callerKey, operation.operationId)
|
||||
)
|
||||
return { store, journal, completed, operation, append }
|
||||
}
|
||||
|
||||
const reopened = await open()
|
||||
const source = reopened.getRecord(SOURCE)!
|
||||
expect(source.conversationCommand).toEqual(marker(fence))
|
||||
expect(reopened.getRecord(REPLACEMENT)).toEqual({
|
||||
schemaVersion: source.schemaVersion,
|
||||
sessionId: REPLACEMENT,
|
||||
location: source.location,
|
||||
provider: 'claude',
|
||||
accountHome: source.accountHome,
|
||||
options: { model: 'sonnet' },
|
||||
launchArgs: ['--flag'],
|
||||
providerHandleChain: [],
|
||||
createdAt: NOW + 5,
|
||||
updatedAt: NOW + 5,
|
||||
lease: {
|
||||
sessionId: REPLACEMENT,
|
||||
runtimeKind: 'native',
|
||||
runtimeFence: 1,
|
||||
handoffStage: null,
|
||||
provenHandleLinkId: null,
|
||||
ownerProcess: null,
|
||||
reservedSpawnToken: null,
|
||||
leaseDeadlineAt: NOW + 5,
|
||||
lastRenewedAt: NOW + 5,
|
||||
handoffOperationId: null,
|
||||
journalCheckpoint: null,
|
||||
claimKeyId: 'key-1',
|
||||
claimStatus: 'released',
|
||||
// Loading marks every lease for this host's adjudication, as it does any record's.
|
||||
unreconciled: true,
|
||||
deathEvidence: null
|
||||
}
|
||||
async function queueClearCards(journal: AgentSessionJournal) {
|
||||
for (const messageId of ['first', 'command', 'last']) {
|
||||
await journal.queuedMessages.insert({
|
||||
messageId,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: messageId }],
|
||||
...(messageId === 'command' ? { command: { name: 'compact' } } : {})
|
||||
},
|
||||
fingerprint: messageId,
|
||||
hostInstance: 'host-1'
|
||||
})
|
||||
expect(reopened.getSessionTabId(REPLACEMENT)).toBe('tab-alpha')
|
||||
expect(reopened.getSessionTabId(SOURCE)).toBeNull()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('writes neither the replacement nor the marker when the commit is refused', async () => {
|
||||
const store = await storeWithSource()
|
||||
const fence = store.getRecord(SOURCE)!.lease.runtimeFence
|
||||
// The source's lease moved after the clear read it: the marker's fenced write refuses.
|
||||
const refused = await store
|
||||
.commitConversationClear({
|
||||
sessionId: SOURCE,
|
||||
fence: fence + 1,
|
||||
command: marker(fence + 1),
|
||||
claimKeyId: 'key-1',
|
||||
now: NOW
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
expect(isAgentSessionRefusalError(refused)).toBe(true)
|
||||
|
||||
for (const each of [store, await open()]) {
|
||||
expect(each.getRecord(REPLACEMENT)).toBeNull()
|
||||
expect(each.getRecord(SOURCE)?.conversationCommand).toBeUndefined()
|
||||
expect(each.getSessionTabId(SOURCE)).toBe('tab-alpha')
|
||||
expect(each.listRecords().map((record) => record.sessionId)).toEqual([SOURCE])
|
||||
}
|
||||
})
|
||||
|
||||
it('never overwrites a record already under the replacement id', async () => {
|
||||
const store = await storeWithSource()
|
||||
const fence = store.getRecord(SOURCE)!.lease.runtimeFence
|
||||
describe('clear transaction', () => {
|
||||
it('publishes one boundary and receipt while preserving the record and tab on disk', async () => {
|
||||
const { store, journal, completed, operation, append } = await prepare()
|
||||
const before = store.getRecord(SOURCE)!
|
||||
const refused = await store
|
||||
.commitConversationClear({
|
||||
sessionId: SOURCE,
|
||||
fence,
|
||||
command: marker(fence, SOURCE),
|
||||
claimKeyId: 'key-1',
|
||||
now: NOW
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
expect(refused).toMatchObject({
|
||||
refusal: { code: 'agent_session_conflict', details: { reason: 'sessionExists' } }
|
||||
expect(before.providerHandleChain).toHaveLength(1)
|
||||
await queueClearCards(journal)
|
||||
const epoch = journal.cursor().epoch
|
||||
await append()
|
||||
const reopened = await open()
|
||||
expect(reopened.listRecords()).toHaveLength(1)
|
||||
expect(reopened.getRecord(SOURCE)).toMatchObject({
|
||||
providerContextBoundary: providerContextBoundaryForClear(completed),
|
||||
providerHandleChain: [],
|
||||
lease: { provenHandleLinkId: null },
|
||||
accountHome: before.accountHome,
|
||||
options: before.options,
|
||||
conversationCommand: completed.command
|
||||
})
|
||||
expect(store.getRecord(SOURCE)).toEqual(before)
|
||||
expect(reopened.getSessionTabId(SOURCE)).toBe('tab-alpha')
|
||||
expect(JSON.stringify(reopened.getRecord(SOURCE))).not.toContain('pre-clear-provider-context')
|
||||
expect(journal.cursor().epoch).toBe(epoch)
|
||||
expect(journal.queuedMessages.get('command')).toMatchObject({
|
||||
state: 'withdrawn',
|
||||
settledByOp: agentSessionOperationKey(operation.callerKey, operation.operationId)
|
||||
})
|
||||
expect(journal.queuedMessages.pauses()).toMatchObject([{ messageIds: ['first', 'last'] }])
|
||||
expect(
|
||||
reopened.getOperationRow(operation.callerKey, operation.operationId)?.outcome.status
|
||||
).toBe('succeeded')
|
||||
})
|
||||
|
||||
it('rolls the divider and receipt back when the fence moved', async () => {
|
||||
const { store, journal, completed, operation, append } = await prepare()
|
||||
await queueClearCards(journal)
|
||||
const before = journal.cursor()
|
||||
const previousChain = store.getRecord(SOURCE)!.providerHandleChain
|
||||
const refused = await append({ ...completed, fence: completed.fence + 1 }).catch(
|
||||
(error: unknown) => error
|
||||
)
|
||||
expect(isAgentSessionRefusalError(refused)).toBe(true)
|
||||
expect(journal.cursor()).toEqual(before)
|
||||
expect(
|
||||
journal.queuedMessages.list().map(({ messageId, state }) => ({ messageId, state }))
|
||||
).toEqual(['first', 'command', 'last'].map((messageId) => ({ messageId, state: 'waiting' })))
|
||||
expect(journal.queuedMessages.pauses()).toEqual([])
|
||||
expect((await open()).getRecord(SOURCE)?.providerContextBoundary).toBeUndefined()
|
||||
expect((await open()).getRecord(SOURCE)?.providerHandleChain).toEqual(previousChain)
|
||||
expect(store.getOperationRow(operation.callerKey, operation.operationId)?.outcome.status).toBe(
|
||||
'pending'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a boundary while ownership is still reserved', async () => {
|
||||
const { store, journal, append } = await prepare(false)
|
||||
const before = journal.cursor()
|
||||
await expect(append()).rejects.toMatchObject({
|
||||
refusal: { code: 'agent_session_ownership_unknown' }
|
||||
})
|
||||
expect(journal.cursor()).toEqual(before)
|
||||
expect(store.getRecord(SOURCE)?.providerContextBoundary).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import { AgentSessionTabTable } from './agent-session-tab-table'
|
||||
import { foundAgentSessionRecord } from './agent-session-record-founding'
|
||||
import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command'
|
||||
|
||||
export function commitConversationCommandRecord(
|
||||
@@ -15,44 +15,47 @@ export function commitConversationCommandRecord(
|
||||
throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' })
|
||||
}
|
||||
state.records.set(sessionId, { ...record, conversationCommand: command })
|
||||
if (
|
||||
command.command === 'clear' &&
|
||||
command.phase === 'committed' &&
|
||||
command.replacementSessionId
|
||||
) {
|
||||
if (!state.records.has(command.replacementSessionId)) {
|
||||
throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' })
|
||||
}
|
||||
state.sessionTabs ??= new AgentSessionTabTable()
|
||||
state.sessionTabs.move(sessionId, command.replacementSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentSessionConversationClear = {
|
||||
sessionId: string
|
||||
fence: number
|
||||
command: AgentSessionConversationCommandRecord & { replacementSessionId: string }
|
||||
claimKeyId: string
|
||||
command: AgentSessionConversationCommandRecord & {
|
||||
command: 'clear'
|
||||
phase: 'committed'
|
||||
state: 'completed'
|
||||
}
|
||||
now: number
|
||||
}
|
||||
|
||||
/** A /clear as one write: the conversation it continues in, founded from the source's identity,
|
||||
* and the marker pointing there. Neither can land without the other. */
|
||||
export function providerContextBoundaryForClear(clear: AgentSessionConversationClear) {
|
||||
return {
|
||||
operationId: createHash('sha256')
|
||||
.update(agentSessionOperationKey(clear.command.callerKey, clear.command.operationId))
|
||||
.digest('hex'),
|
||||
afterFence: clear.fence,
|
||||
clearedAt: clear.now
|
||||
}
|
||||
}
|
||||
|
||||
/** The record owns the provider boundary; the journal transaction publishes its divider. */
|
||||
export function commitConversationClearRecord(
|
||||
state: AgentSessionStoreState,
|
||||
clear: AgentSessionConversationClear
|
||||
): void {
|
||||
const source = state.records.get(clear.sessionId)
|
||||
const replacementId = clear.command.replacementSessionId
|
||||
if (!source) {
|
||||
if (!source || source.lease.runtimeFence !== clear.fence) {
|
||||
throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' })
|
||||
}
|
||||
if (state.records.has(replacementId) || state.unreadableRecords.has(replacementId)) {
|
||||
throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' })
|
||||
if (source.lease.ownerProcess !== null || source.lease.claimStatus !== 'released') {
|
||||
throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' })
|
||||
}
|
||||
state.records.set(
|
||||
replacementId,
|
||||
foundAgentSessionRecord({ ...source, sessionId: replacementId }, clear)
|
||||
)
|
||||
commitConversationCommandRecord(state, clear.sessionId, clear.fence, clear.command)
|
||||
state.records.set(clear.sessionId, {
|
||||
...source,
|
||||
providerHandleChain: [],
|
||||
lease: { ...source.lease, provenHandleLinkId: null },
|
||||
conversationCommand: clear.command,
|
||||
providerContextBoundary: providerContextBoundaryForClear(clear),
|
||||
updatedAt: clear.now
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AgentSessionStoreTransactions } from './agent-session-store-transactions'
|
||||
import {
|
||||
commitConversationClearRecord,
|
||||
type AgentSessionConversationClear
|
||||
} from './agent-session-conversation-command-record'
|
||||
import { settleAgentSessionOperationInto } from './agent-session-operation-admission'
|
||||
import { commitAgentSessionRewindCompletion } from './agent-session-rewind-completion'
|
||||
|
||||
export function createAgentSessionConversationReceipts(
|
||||
transactions: Pick<AgentSessionStoreTransactions, 'receipt'>
|
||||
) {
|
||||
return {
|
||||
clear: (
|
||||
clear: () => AgentSessionConversationClear,
|
||||
operation: { callerKey: string; operationId: string }
|
||||
) =>
|
||||
transactions.receipt((draft) => {
|
||||
const completed = clear()
|
||||
commitConversationClearRecord(draft, completed)
|
||||
settleAgentSessionOperationInto(draft, {
|
||||
...operation,
|
||||
outcome: {
|
||||
status: 'succeeded',
|
||||
sessionId: completed.sessionId,
|
||||
conversationCommand: completed.command
|
||||
}
|
||||
})
|
||||
}),
|
||||
rewind: (
|
||||
...args: Parameters<typeof commitAgentSessionRewindCompletion> extends [unknown, ...infer A]
|
||||
? A
|
||||
: never
|
||||
) => transactions.receipt((draft) => commitAgentSessionRewindCompletion(draft, ...args))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
|
||||
import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form'
|
||||
import { activeProviderContext } from '../../shared/agent-session-provider-context'
|
||||
import {
|
||||
recordAgentSessionProviderHandle,
|
||||
reviseAgentSessionProviderResumePoint
|
||||
@@ -54,6 +55,43 @@ describe('recordAgentSessionProviderHandle', () => {
|
||||
expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ resumeCursor: 'leaf-2' })
|
||||
expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null })
|
||||
})
|
||||
|
||||
it('rejects delayed old-child proof and records fresh creation behind the clear boundary', () => {
|
||||
const previous = agentSessionRecordFixture()
|
||||
const record = {
|
||||
...previous,
|
||||
providerHandleChain: [],
|
||||
lease: { ...previous.lease, runtimeFence: 8 },
|
||||
providerContextBoundary: { operationId: 'clear', afterFence: 7, clearedAt: 4500 }
|
||||
}
|
||||
expect(() =>
|
||||
recordAgentSessionProviderHandle({
|
||||
record,
|
||||
fence: 7,
|
||||
link: resumedLink(7),
|
||||
now: 5000
|
||||
})
|
||||
).toThrow('agent_session_stale_fence')
|
||||
expect(activeProviderContext(record).head).toBeNull()
|
||||
const fresh = recordAgentSessionProviderHandle({
|
||||
record,
|
||||
fence: 8,
|
||||
now: 5000,
|
||||
link: {
|
||||
linkId: 'fresh',
|
||||
origin: 'created',
|
||||
mintedAtFence: 8,
|
||||
observedAt: 5000,
|
||||
handle: claudeProviderHandle('fresh-context', null)
|
||||
}
|
||||
})
|
||||
expect(fresh.providerHandleChain).toHaveLength(1)
|
||||
expect(activeProviderContext(fresh).head).toMatchObject({
|
||||
linkId: 'fresh',
|
||||
origin: 'created'
|
||||
})
|
||||
expect(fresh.providerHandleChain[0]?.replaces).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reviseAgentSessionProviderResumePoint', () => {
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
* journal database. */
|
||||
|
||||
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
|
||||
import {
|
||||
commitConversationClearRecord,
|
||||
commitConversationCommandRecord,
|
||||
type AgentSessionConversationClear
|
||||
} from './agent-session-conversation-command-record'
|
||||
import { createAgentSessionConversationReceipts } from './agent-session-conversation-receipts'
|
||||
import { commitConversationCommandRecord } from './agent-session-conversation-command-record'
|
||||
import { pinAgentSessionRecordLaunchDirectory } from './agent-session-record-launch-directory'
|
||||
import {
|
||||
agentSessionOperationKey,
|
||||
@@ -90,11 +87,14 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000,
|
||||
export class AgentSessionRecordStore {
|
||||
private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>()
|
||||
private readonly firstRecordListeners = new Set<() => void>()
|
||||
readonly conversationReceipts: ReturnType<typeof createAgentSessionConversationReceipts>
|
||||
|
||||
private constructor(
|
||||
private readonly transactions: AgentSessionStoreTransactions,
|
||||
readonly hostId: string
|
||||
) {}
|
||||
) {
|
||||
this.conversationReceipts = createAgentSessionConversationReceipts(transactions)
|
||||
}
|
||||
|
||||
/** Reads every structurally valid row, independently of which agents this host can start. */
|
||||
static open(args: {
|
||||
@@ -164,10 +164,6 @@ export class AgentSessionRecordStore {
|
||||
)
|
||||
}
|
||||
|
||||
/** A committed /clear and the at-rest conversation it continues in, in one write. */
|
||||
commitConversationClear = (clear: AgentSessionConversationClear): Promise<void> =>
|
||||
this.transact((draft) => commitConversationClearRecord(draft, clear))
|
||||
|
||||
/** Unfenced on purpose: the name is a durable note, so writing it never contends with the
|
||||
* writer lease. `null` clears it. */
|
||||
setConversationName = (sessionId: string, name: string | null): Promise<AgentSessionRecord> =>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AgentJournalCursor } from '../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionRewindRecord } from '../../shared/agent-session-rewind'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import { settleAgentSessionOperationInto } from './agent-session-operation-admission'
|
||||
|
||||
export function commitAgentSessionRewindCompletion(
|
||||
draft: AgentSessionStoreState,
|
||||
sessionId: string,
|
||||
fence: number,
|
||||
rewind: AgentSessionRewindRecord,
|
||||
cursor: AgentJournalCursor
|
||||
): void {
|
||||
const record = draft.records.get(sessionId)
|
||||
if (
|
||||
!record ||
|
||||
record.lease.runtimeFence !== fence ||
|
||||
record.providerContextBoundary?.operationId !== rewind.contextClearOperationId
|
||||
) {
|
||||
throw new Error('agent_session_rewind:stale-context')
|
||||
}
|
||||
draft.records.set(sessionId, {
|
||||
...record,
|
||||
rewind: {
|
||||
...rewind,
|
||||
phase: 'completed',
|
||||
epoch: cursor.epoch,
|
||||
sequence: cursor.sequence,
|
||||
retained: []
|
||||
}
|
||||
})
|
||||
settleAgentSessionOperationInto(draft, {
|
||||
callerKey: rewind.callerKey,
|
||||
operationId: rewind.operationId,
|
||||
outcome: { status: 'succeeded', sessionId, rewind: { itemId: rewind.itemId, ...cursor } }
|
||||
})
|
||||
}
|
||||
@@ -6,8 +6,7 @@ import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
* Which conversation each structured chat tab shows, keyed by the host tab id.
|
||||
*
|
||||
* Membership is visibility: a session with an entry has a chat tab, and the key is that tab's id.
|
||||
* A /clear moves the tab to the replacement conversation rather than copying its id, so an id names
|
||||
* one conversation by construction and a session maps back to at most one tab.
|
||||
* An id names one conversation, and a session maps back to at most one tab.
|
||||
*/
|
||||
export class AgentSessionTabTable {
|
||||
private readonly sessionByTab = new Map<string, string>()
|
||||
@@ -38,15 +37,14 @@ export class AgentSessionTabTable {
|
||||
|
||||
/**
|
||||
* Gives a session a tab unless it already has one. Without a reserved id it gets the id clients
|
||||
* derive for it, unless a cleared conversation's tab kept that id.
|
||||
* derive for it.
|
||||
*/
|
||||
show(sessionId: string, tabId?: string): void {
|
||||
if (this.tabBySession.has(sessionId)) {
|
||||
return
|
||||
}
|
||||
const derived = structuredAgentSessionTabId(sessionId)
|
||||
const held = (candidate: string): boolean => this.sessionByTab.has(candidate)
|
||||
this.put(tabId ?? (held(derived) ? reopenedTabId(sessionId, held) : derived), sessionId)
|
||||
this.put(tabId ?? derived, sessionId)
|
||||
}
|
||||
|
||||
/** Returns the id the session's tab had, if it had one. */
|
||||
@@ -59,19 +57,6 @@ export class AgentSessionTabTable {
|
||||
return tabId
|
||||
}
|
||||
|
||||
/** A /clear: the tab that showed `fromSessionId` shows `toSessionId`, keeping its id and place. */
|
||||
move(fromSessionId: string, toSessionId: string): void {
|
||||
const tabId = this.tabBySession.get(fromSessionId)
|
||||
this.hide(toSessionId)
|
||||
if (tabId === undefined) {
|
||||
this.show(toSessionId)
|
||||
return
|
||||
}
|
||||
this.tabBySession.delete(fromSessionId)
|
||||
this.sessionByTab.set(tabId, toSessionId)
|
||||
this.tabBySession.set(toSessionId, tabId)
|
||||
}
|
||||
|
||||
clone(): AgentSessionTabTable {
|
||||
return new AgentSessionTabTable(this.sessionByTab)
|
||||
}
|
||||
@@ -99,19 +84,6 @@ export class AgentSessionTabTable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The id a cleared conversation reopened from history takes while the tab now showing its
|
||||
* replacement holds its own. Deterministic, so a table seeded again gives it the same id.
|
||||
*/
|
||||
function reopenedTabId(sessionId: string, held: (tabId: string) => boolean): string {
|
||||
const base = `${structuredAgentSessionTabId(sessionId)}-reopened`
|
||||
let tabId = base
|
||||
for (let suffix = 2; held(tabId); suffix++) {
|
||||
tabId = `${base}-${suffix}`
|
||||
}
|
||||
return tabId
|
||||
}
|
||||
|
||||
/** The sessions whose chat tab is shown, in tab order, skipping any without a record. */
|
||||
export function listVisibleAgentSessionIds(state: AgentSessionStoreState): string[] {
|
||||
return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => state.records.has(sessionId))
|
||||
|
||||
@@ -157,8 +157,7 @@ describe('a Claude start whose CLI answers initialize but not a control request'
|
||||
})
|
||||
|
||||
it('launches the saved model again after a turn reports another model, another option changes, and the chat is cleared', async () => {
|
||||
const sessions = [SESSION]
|
||||
claude = createScriptedClaudeRuntime(sessions)
|
||||
claude = createScriptedClaudeRuntime([SESSION])
|
||||
const behavior = { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS }
|
||||
claude.behave(SESSION, behavior)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
@@ -202,31 +201,32 @@ describe('a Claude start whose CLI answers initialize but not a control request'
|
||||
})
|
||||
expect(cleared, JSON.stringify(cleared)).toMatchObject({
|
||||
ok: true,
|
||||
value: { replacementSessionId: expect.any(String) }
|
||||
value: { command: 'clear', state: 'completed' }
|
||||
})
|
||||
const replacement = cleared.ok ? cleared.value.replacementSessionId! : ''
|
||||
sessions.push(replacement)
|
||||
claude.behave(replacement, LIVE_START)
|
||||
expect(cleared.ok && cleared.value.replacementSessionId).toBeUndefined()
|
||||
const boundary = record(host)?.providerContextBoundary
|
||||
expect(boundary).toBeDefined()
|
||||
const freshProviderSessionId = claudeSessionIdForOrcaSession(SESSION, boundary?.operationId)
|
||||
expect(freshProviderSessionId).not.toBe(claudeSessionIdForOrcaSession(SESSION))
|
||||
claude.behave(SESSION, LIVE_START)
|
||||
// The cleared chat starts nothing until its first message.
|
||||
expect(claude.children(replacement)).toEqual([])
|
||||
await sendTo(host, replacement, 'first message')
|
||||
// Started fresh under its own derived id, launched with the saved model rather than the one
|
||||
// the turn reported.
|
||||
await vi.waitFor(() => expect(claude.children(replacement)).toHaveLength(1))
|
||||
expect(claude.child(replacement).launch.options).toMatchObject({
|
||||
sessionId: claudeSessionIdForOrcaSession(replacement),
|
||||
expect(claude.children(SESSION)).toHaveLength(1)
|
||||
expect(claude.child(SESSION).connection.closed).toBe(true)
|
||||
await send(host, 'first message')
|
||||
await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2))
|
||||
expect(claude.child(SESSION).launch.options).toMatchObject({
|
||||
sessionId: freshProviderSessionId,
|
||||
model: 'sonnet',
|
||||
permissionMode: 'plan'
|
||||
})
|
||||
expect(claude.child(replacement).launch.options.resume).toBeUndefined()
|
||||
expect(claude.child(SESSION).launch.options.resume).toBeUndefined()
|
||||
await vi.waitFor(() =>
|
||||
expect(host.deps.store.getRecord(replacement)?.options).toEqual({
|
||||
expect(record(host)?.options).toEqual({
|
||||
model: 'sonnet',
|
||||
effort: 'high',
|
||||
permissionMode: 'plan'
|
||||
})
|
||||
)
|
||||
expect(record(host)?.options).toEqual({ model: 'sonnet', permissionMode: 'plan' })
|
||||
})
|
||||
|
||||
it('replaces the launched saved model with the one the user then sets', async () => {
|
||||
|
||||
@@ -24,7 +24,6 @@ import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-bui
|
||||
import { FIRST_PANE_ID } from '../../shared/pane-key'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement'
|
||||
import { structuredWorkerHandleAgentStatus } from './orchestration/structured-worker-group-addressing'
|
||||
import {
|
||||
retitleStructuredConversationTab,
|
||||
@@ -81,9 +80,6 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime
|
||||
protected toMobileSessionTabsResult(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
for (const replacement of getStructuredAgentSessionHost()?.conversationReplacements?.() ?? []) {
|
||||
snapshot = replaceConversationInSnapshot(snapshot, replacement)
|
||||
}
|
||||
const host = getStructuredAgentSessionHost()
|
||||
snapshot = titleStructuredConversationTabs(snapshot, (sessionId) => {
|
||||
const record = host?.deps?.store?.getRecord(sessionId)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label'
|
||||
import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './orca-runtime-get-structured-agent-session-create-support'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement'
|
||||
import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command'
|
||||
import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration'
|
||||
import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
@@ -30,27 +28,6 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
|
||||
|
||||
export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport {
|
||||
/** Projects only: a replacement's chat already has its tab in the store, which the /clear commit
|
||||
* moved in the same write. */
|
||||
replaceStructuredAgentSessionTab(replacement: ConversationReplacement): void {
|
||||
const prior = this.mobileSessionTabsByWorktree.get(replacement.workspaceId)
|
||||
const next = prior ? replaceConversationInSnapshot(prior, replacement) : null
|
||||
if (next && next !== prior) {
|
||||
const stored = this.storeMobileSessionSnapshot(replacement.workspaceId, next)
|
||||
this.emitMobileSessionTabsSnapshot(stored)
|
||||
} else if (
|
||||
!prior?.tabs.some(
|
||||
(tab) => tab.type === 'agent-session' && tab.sessionId === replacement.sessionId
|
||||
)
|
||||
) {
|
||||
this.projectStructuredAgentSessionTab({
|
||||
...replacement,
|
||||
replacesSessionId: replacement.sourceSessionId,
|
||||
activate: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
protected async restoreStructuredAgentSessionTabsOnce(): Promise<void> {
|
||||
await this.prepareStructuredAgentSessionStartupRestoration()
|
||||
const host = getStructuredAgentSessionHost()
|
||||
@@ -84,9 +61,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
|
||||
restored.map((session) => session.sessionId)
|
||||
)
|
||||
// Past the seed, projecting records nothing.
|
||||
for (const replacement of host?.conversationReplacements?.() ?? []) {
|
||||
this.replaceStructuredAgentSessionTab(replacement)
|
||||
}
|
||||
for (const session of restored) {
|
||||
this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false })
|
||||
}
|
||||
@@ -106,7 +80,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
|
||||
agent: StructuredAgentId
|
||||
activate: boolean
|
||||
notify?: boolean
|
||||
replacesSessionId?: string
|
||||
/** The host tab id a create reserved; a session that already has a tab keeps its own. */
|
||||
tabId?: string
|
||||
}): Promise<void> {
|
||||
@@ -134,7 +107,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
|
||||
agent: StructuredAgentId
|
||||
activate: boolean
|
||||
notify?: boolean
|
||||
replacesSessionId?: string
|
||||
}): void {
|
||||
const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId)
|
||||
const id = `agent-session:${input.sessionId}`
|
||||
@@ -172,7 +144,6 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
|
||||
id,
|
||||
title: defaultAgentChatLabel(input.agent),
|
||||
sessionId: input.sessionId,
|
||||
...(input.replacesSessionId ? { replacesSessionId: input.replacesSessionId } : {}),
|
||||
agent: input.agent,
|
||||
isActive: input.activate
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
@@ -107,9 +105,6 @@ export class OrcaRuntimeWithRuntimeId {
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot
|
||||
): RuntimeMobileSessionTabsSnapshot {
|
||||
for (const replacement of getStructuredAgentSessionHost()?.conversationReplacements?.() ?? []) {
|
||||
snapshot = replaceConversationInSnapshot(snapshot, replacement)
|
||||
}
|
||||
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
snapshot = preserveTerminalRetirementProofs(snapshot, existing)
|
||||
const snapshotVersion = existing
|
||||
|
||||
@@ -7,8 +7,8 @@ afterEach(() => setStructuredAgentSessionHost(null))
|
||||
describe('structured session cold restoration', () => {
|
||||
it('publishes a versioned title change and derives listAll titles from the record', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.replaceStructuredAgentSessionTab({
|
||||
sourceSessionId: 'source-session',
|
||||
runtime.projectStructuredAgentSessionTab({
|
||||
activate: false,
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
agent: 'claude'
|
||||
@@ -408,30 +408,6 @@ describe('structured session cold restoration', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// The /clear commit moved the tab in the store already, so replacing it only projects.
|
||||
it('replaces a cleared conversation tab without a store write', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const setSessionTabVisibility = vi.fn(async () => undefined)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: replacing a tab reaches the host only through setSessionTabVisibility, which must stay uncalled.
|
||||
setStructuredAgentSessionHost({ setSessionTabVisibility } as never)
|
||||
|
||||
runtime.replaceStructuredAgentSessionTab({
|
||||
sourceSessionId: 'cleared-session',
|
||||
sessionId: 'replacement-session',
|
||||
workspaceId: 'workspace-1',
|
||||
agent: 'codex'
|
||||
})
|
||||
|
||||
const snapshot = await runtime.listMobileSessionTabs('id:workspace-1')
|
||||
expect(snapshot.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'agent-session:replacement-session',
|
||||
replacesSessionId: 'cleared-session'
|
||||
})
|
||||
])
|
||||
expect(setSessionTabVisibility).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits the host close when the renderer already removed the structured tab', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setNotifier({
|
||||
|
||||
@@ -132,6 +132,7 @@ async function renderChatPointer(mailbox: string): Promise<string> {
|
||||
host: {
|
||||
readSessionFacts: async () => ({ submissions: [] }),
|
||||
currentFence: () => 1,
|
||||
currentContextClearOperationId: () => undefined,
|
||||
send: async (input) => {
|
||||
for (const block of input.body.blocks) {
|
||||
texts.push(block.type === 'text' ? block.text : '')
|
||||
|
||||
@@ -54,6 +54,9 @@ function harness(options: {
|
||||
: { kind: 'sent' as const, state: options.dispatchState ?? ('accepted' as const) }
|
||||
)
|
||||
const sendMock = vi.mocked(send)
|
||||
const readContext = vi.fn<StructuredMailboxPointerHost['currentContextClearOperationId']>(
|
||||
() => undefined
|
||||
)
|
||||
const stored = new Map<string, StructuredPointerOperationRow>()
|
||||
const db = {
|
||||
getDispatchContextById: () => ({ run_id: 'run_1' }),
|
||||
@@ -77,6 +80,7 @@ function harness(options: {
|
||||
host: {
|
||||
readSessionFacts: async () => (attached ? { submissions } : null),
|
||||
currentFence: () => 4,
|
||||
currentContextClearOperationId: readContext,
|
||||
send
|
||||
}
|
||||
})
|
||||
@@ -84,6 +88,7 @@ function harness(options: {
|
||||
delivery,
|
||||
markAsDelivered,
|
||||
send: sendMock,
|
||||
readContext,
|
||||
stored,
|
||||
setAttached: (next: boolean) => {
|
||||
attached = next
|
||||
@@ -100,6 +105,35 @@ function harness(options: {
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
describe('structured mailbox pointer delivery', () => {
|
||||
it.each(['rejected', 'unknown', 'throws'] as const)(
|
||||
'attempts an unchanged record context once when its journal has no marker and send is %s',
|
||||
async (outcome) => {
|
||||
const { delivery, send, readContext, stored, markAsDelivered } = harness({})
|
||||
let contextReads = 0
|
||||
readContext.mockImplementation(() => {
|
||||
if (++contextReads > 8) {
|
||||
throw new Error('hard regression-test cap on context reads')
|
||||
}
|
||||
return 'record-clear-1'
|
||||
})
|
||||
send.mockImplementation(async () => {
|
||||
if (outcome === 'throws') {
|
||||
throw new Error('send failed')
|
||||
}
|
||||
return { kind: 'sent', state: outcome }
|
||||
})
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
expect(contextReads).toBeLessThan(8)
|
||||
expect(markAsDelivered).not.toHaveBeenCalled()
|
||||
expect(stored.get('dispatch:d1')?.batch_fingerprint).toBe(
|
||||
structuredPointerBatchFingerprint(IDENTITY.sessionId, ['m1'], 'record-clear-1')
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('claims only mailboxes whose assignee is a structured worker', () => {
|
||||
const { delivery } = harness({})
|
||||
expect(delivery.deliverForHandle('dispatch:d1')).toBe(true)
|
||||
@@ -133,6 +167,20 @@ describe('structured mailbox pointer delivery', () => {
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(['m1'])
|
||||
})
|
||||
|
||||
it('releases its in-flight lane when reading the context fails', async () => {
|
||||
const { delivery, send, readContext, markAsDelivered } = harness({})
|
||||
readContext.mockImplementationOnce(() => {
|
||||
throw new Error('record unavailable')
|
||||
})
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledOnce()
|
||||
expect(markAsDelivered).toHaveBeenCalledWith(['m1'])
|
||||
})
|
||||
|
||||
it('retains mail when the dispatch settles unknown', async () => {
|
||||
const { delivery, markAsDelivered } = harness({
|
||||
dispatchState: 'unknown'
|
||||
@@ -427,6 +475,7 @@ describe('forgetting one settled worker', () => {
|
||||
host: {
|
||||
readSessionFacts: async () => (attached ? { submissions: [] } : null),
|
||||
currentFence: () => 4,
|
||||
currentContextClearOperationId: () => undefined,
|
||||
send
|
||||
}
|
||||
})
|
||||
@@ -485,7 +534,8 @@ describe('forgetting one settled worker', () => {
|
||||
})
|
||||
|
||||
describe('a mailbox a /clear moves while its nudge is in flight', () => {
|
||||
function clearingHarness() {
|
||||
function clearingHarness(inPlace = false) {
|
||||
let contextClearOperationId: string | undefined
|
||||
let target = 'session-1'
|
||||
const mail = [
|
||||
{ id: 'm1', type: 'status', sequence: 3, from_handle: 'term_coord', run_id: 'run_1' }
|
||||
@@ -503,10 +553,11 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => {
|
||||
deleteStructuredPointerOperation: (key: string) => stored.delete(key)
|
||||
}
|
||||
let settleHeld: ((state: 'accepted' | 'rejected' | 'throws') => void) | null = null
|
||||
let sendAttempts = 0
|
||||
const send = vi.fn(
|
||||
({ sessionId }: { sessionId: string }) =>
|
||||
(_input: Parameters<StructuredMailboxPointerHost['send']>[0]) =>
|
||||
new Promise<{ kind: 'sent'; state: 'accepted' | 'rejected' }>((resolve, reject) => {
|
||||
if (sessionId === 'session-1') {
|
||||
if (++sendAttempts === 1) {
|
||||
settleHeld = (state) =>
|
||||
state === 'throws'
|
||||
? reject(new Error('the operation-admission write failed'))
|
||||
@@ -525,6 +576,7 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => {
|
||||
host: {
|
||||
readSessionFacts: async () => ({ submissions: [] }),
|
||||
currentFence: () => 4,
|
||||
currentContextClearOperationId: () => contextClearOperationId,
|
||||
send
|
||||
}
|
||||
})
|
||||
@@ -533,7 +585,11 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => {
|
||||
send,
|
||||
pointed,
|
||||
clear: () => {
|
||||
target = 'clear-successor'
|
||||
if (inPlace) {
|
||||
contextClearOperationId = 'clear-1'
|
||||
} else {
|
||||
target = 'clear-successor'
|
||||
}
|
||||
},
|
||||
settle: (state: 'accepted' | 'rejected' | 'throws') => settleHeld?.(state)
|
||||
}
|
||||
@@ -561,6 +617,25 @@ describe('a mailbox a /clear moves while its nudge is in flight', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['rejected', 'throws'] as const)(
|
||||
'delivers once to the fresh context when the in-flight old send is %s',
|
||||
async (outcome) => {
|
||||
const { delivery, send, pointed, clear, settle } = clearingHarness(true)
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
clear()
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
await flush()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
settle(outcome)
|
||||
await flush()
|
||||
await flush()
|
||||
expect(send.mock.calls.map(([input]) => input.sessionId)).toEqual(['session-1', 'session-1'])
|
||||
expect(send.mock.calls[1]![0].operationId).not.toBe(send.mock.calls[0]![0].operationId)
|
||||
expect(pointed.has('m1')).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an unchanged rejected nudge parked for the next edge', async () => {
|
||||
const { delivery, send, settle } = clearingHarness()
|
||||
delivery.deliverForHandle('dispatch:d1')
|
||||
|
||||
@@ -56,7 +56,7 @@ export type StructuredPointerSendOutcome =
|
||||
| { kind: 'unattached' }
|
||||
|
||||
export type StructuredPointerSessionFacts = {
|
||||
/** Every send the session recorded, oldest first: what the lane's own sends settled as. */
|
||||
/** Current-context sends, oldest first: what the lane's own sends settled as. */
|
||||
submissions: readonly StructuredPointerSubmission[]
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export type StructuredMailboxPointerHost = {
|
||||
}) => Promise<StructuredPointerSendOutcome>
|
||||
/** Current lease fence; `null` when no record backs the session any more. */
|
||||
currentFence: (sessionId: string) => number | null
|
||||
currentContextClearOperationId: (sessionId: string) => string | undefined
|
||||
}
|
||||
|
||||
type StructuredPointerDeliveryDependencies<TWaiter extends OrchestrationMessageWaiter> = {
|
||||
@@ -165,7 +166,7 @@ export class OrchestrationStructuredMailboxPointerDelivery<
|
||||
mailboxHandle: string,
|
||||
target: StructuredPointerTarget,
|
||||
reservedTypes?: ReadonlySet<string>,
|
||||
attemptedSessions = new Set<string>()
|
||||
attemptedContexts = new Set<string>()
|
||||
): Promise<void> {
|
||||
const db = this.deps.getDb()
|
||||
if (!db || this.inFlight.has(mailboxHandle)) {
|
||||
@@ -189,37 +190,63 @@ export class OrchestrationStructuredMailboxPointerDelivery<
|
||||
return
|
||||
}
|
||||
this.inFlight.add(mailboxHandle)
|
||||
let contextKey: string | undefined
|
||||
try {
|
||||
await this.attempt(db, mailboxHandle, target, unread, reservedTypes)
|
||||
const session = await this.deps.host.readSessionFacts(target.sessionId)
|
||||
const contextClearOperationId = this.deps.host.currentContextClearOperationId(
|
||||
target.sessionId
|
||||
)
|
||||
contextKey = JSON.stringify([target.sessionId, contextClearOperationId])
|
||||
if (attemptedContexts.has(contextKey)) {
|
||||
return
|
||||
}
|
||||
attemptedContexts.add(contextKey)
|
||||
await this.attempt(
|
||||
db,
|
||||
mailboxHandle,
|
||||
target,
|
||||
unread,
|
||||
reservedTypes,
|
||||
session,
|
||||
contextClearOperationId
|
||||
)
|
||||
} finally {
|
||||
this.inFlight.delete(mailboxHandle)
|
||||
// A thrown attempt follows too; its own failure is what still propagates.
|
||||
await this.followMovedTarget(mailboxHandle, target, reservedTypes, attemptedSessions).catch(
|
||||
() => undefined
|
||||
)
|
||||
if (contextKey !== undefined) {
|
||||
await this.followChangedContext(
|
||||
mailboxHandle,
|
||||
target,
|
||||
reservedTypes,
|
||||
attemptedContexts
|
||||
).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `/clear` while the attempt was in flight moved the mailbox to a successor, whose idle edge
|
||||
* found it in flight and was dropped; nothing else retries it. Only an actual move retries, so
|
||||
* an unchanged rejected or unknown send keeps its suppression, and each session is tried once.
|
||||
*/
|
||||
private async followMovedTarget(
|
||||
/** Clear's idle edge can land during a send; retry only a changed context, once per context. */
|
||||
private async followChangedContext(
|
||||
mailboxHandle: string,
|
||||
attempted: StructuredPointerTarget,
|
||||
reservedTypes: ReadonlySet<string> | undefined,
|
||||
attemptedSessions: Set<string>
|
||||
attemptedContexts: Set<string>
|
||||
): Promise<void> {
|
||||
attemptedSessions.add(attempted.sessionId)
|
||||
const current = this.deps.resolveStructuredTarget(mailboxHandle)
|
||||
if (!current || attemptedSessions.has(current.sessionId)) {
|
||||
if (
|
||||
!current ||
|
||||
attemptedContexts.has(
|
||||
JSON.stringify([
|
||||
current.sessionId,
|
||||
this.deps.host.currentContextClearOperationId(current.sessionId)
|
||||
])
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (this.parkedUntilJournalEdge.get(mailboxHandle)?.sessionId === attempted.sessionId) {
|
||||
this.parkedUntilJournalEdge.delete(mailboxHandle)
|
||||
}
|
||||
await this.deliver(mailboxHandle, current, reservedTypes, attemptedSessions)
|
||||
await this.deliver(mailboxHandle, current, reservedTypes, attemptedContexts)
|
||||
}
|
||||
|
||||
// A session whose agent is not running needs nothing first: an accepted send starts it.
|
||||
@@ -228,10 +255,11 @@ export class OrchestrationStructuredMailboxPointerDelivery<
|
||||
mailboxHandle: string,
|
||||
target: StructuredPointerTarget,
|
||||
unread: readonly MessageRow[],
|
||||
reservedTypes: ReadonlySet<string> | undefined
|
||||
reservedTypes: ReadonlySet<string> | undefined,
|
||||
session: StructuredPointerSessionFacts | null,
|
||||
contextClearOperationId: string | undefined
|
||||
): Promise<void> {
|
||||
const sessionId = target.sessionId
|
||||
const session = await this.deps.host.readSessionFacts(sessionId)
|
||||
if (!session) {
|
||||
this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes)
|
||||
return
|
||||
@@ -265,6 +293,7 @@ export class OrchestrationStructuredMailboxPointerDelivery<
|
||||
sessionId,
|
||||
messageIds: staged,
|
||||
submissions: session.submissions,
|
||||
contextClearOperationId,
|
||||
sentByThisProcess: this.sentOperationIds.get(mailboxHandle)
|
||||
})
|
||||
if (operation.kind === 'stamp') {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
|
||||
|
||||
const hostRef: { current: unknown } = { current: null }
|
||||
@@ -35,6 +38,24 @@ function transcript(count: number): AgentJournalRenderItem[] {
|
||||
)
|
||||
}
|
||||
|
||||
function pointerSubmission(
|
||||
clientMessageId: string,
|
||||
fence: number,
|
||||
acceptedSequence: number
|
||||
): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId,
|
||||
acceptedSequence,
|
||||
submittedAt: 1_000,
|
||||
fence,
|
||||
payloadFingerprint: 'fp',
|
||||
dispatchState: 'pending',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
resolvedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
const NOTICE_SOURCE: AgentMessageSource = {
|
||||
kind: 'agent',
|
||||
senders: [],
|
||||
@@ -61,12 +82,61 @@ describe('structured mailbox pointer host', () => {
|
||||
|
||||
it("reads what the session's sends settled as", async () => {
|
||||
const submissions = [{ clientMessageId: 'op1', dispatchState: 'unknown' }]
|
||||
hostRef.current = { journalSnapshot: () => ({ items: [], submissions }) }
|
||||
hostRef.current = {
|
||||
deps: { store: { getRecord: () => null } },
|
||||
journalSnapshot: () => ({ items: [], submissions })
|
||||
}
|
||||
expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toEqual({
|
||||
submissions
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes settlements to the record fence when the journal marker disagrees', async () => {
|
||||
const items: AgentJournalRenderItem[] = [
|
||||
{
|
||||
itemId: 'clear-marker',
|
||||
revision: 1,
|
||||
sequence: 5,
|
||||
observedAt: 1_000,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Context cleared',
|
||||
contextClear: { operationId: 'clear-old', afterFence: 1, clearedAt: 1_000 }
|
||||
}
|
||||
}
|
||||
]
|
||||
const current = pointerSubmission('current', 3, 4)
|
||||
hostRef.current = {
|
||||
deps: {
|
||||
store: {
|
||||
getRecord: () => ({ providerContextBoundary: { operationId: 'clear-1', afterFence: 2 } })
|
||||
}
|
||||
},
|
||||
journalSnapshot: () => ({ items, submissions: [pointerSubmission('earlier', 2, 6), current] })
|
||||
}
|
||||
const host = createStructuredMailboxPointerHost()
|
||||
expect(await host.readSessionFacts('s1')).toEqual({ submissions: [current] })
|
||||
expect(host.currentContextClearOperationId('s1')).toBe('clear-1')
|
||||
})
|
||||
|
||||
it('keeps the record context when an older rewind removed its journal marker', async () => {
|
||||
const current = pointerSubmission('current', 3, 1)
|
||||
hostRef.current = {
|
||||
deps: {
|
||||
store: {
|
||||
getRecord: () => ({ providerContextBoundary: { operationId: 'clear-1', afterFence: 2 } })
|
||||
}
|
||||
},
|
||||
journalSnapshot: () => ({
|
||||
items: [],
|
||||
submissions: [pointerSubmission('earlier', 2, 2), current]
|
||||
})
|
||||
}
|
||||
const host = createStructuredMailboxPointerHost()
|
||||
expect(await host.readSessionFacts('s1')).toEqual({ submissions: [current] })
|
||||
expect(host.currentContextClearOperationId('s1')).toBe('clear-1')
|
||||
})
|
||||
|
||||
it('answers null rather than nothing recorded when the session cannot be read', async () => {
|
||||
// Null retains the pointer; an empty answer would send into a session this runtime cannot see.
|
||||
expect(await createStructuredMailboxPointerHost().readSessionFacts('s1')).toBeNull()
|
||||
|
||||
@@ -55,7 +55,16 @@ async function readPointerSessionFacts(
|
||||
sessionId: string
|
||||
): Promise<StructuredPointerSessionFacts | null> {
|
||||
const snapshot = await readSessionJournal(sessionId)
|
||||
return snapshot ? { submissions: snapshot.submissions } : null
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
const boundary =
|
||||
getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.providerContextBoundary
|
||||
return {
|
||||
submissions: boundary
|
||||
? snapshot.submissions.filter((submission) => submission.fence > boundary.afterFence)
|
||||
: snapshot.submissions
|
||||
}
|
||||
}
|
||||
|
||||
async function readSessionJournal(sessionId: string): Promise<AgentJournalSnapshot | null> {
|
||||
@@ -87,6 +96,11 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo
|
||||
)
|
||||
},
|
||||
|
||||
currentContextClearOperationId(sessionId) {
|
||||
return getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)
|
||||
?.providerContextBoundary?.operationId
|
||||
},
|
||||
|
||||
async send(input) {
|
||||
const host = getStructuredAgentSessionHost()
|
||||
if (!host) {
|
||||
|
||||
@@ -67,6 +67,32 @@ describe('structured pointer operation id', () => {
|
||||
expect(second.operationId).toBe(first.operationId)
|
||||
})
|
||||
|
||||
it('re-mints an unresolved batch once for a fresh context in the same conversation', () => {
|
||||
const db = fakeDb()
|
||||
const input = {
|
||||
db,
|
||||
mailboxHandle: 'dispatch:d1',
|
||||
sessionId: 's1',
|
||||
messageIds: ['m1'],
|
||||
now: 1_000
|
||||
}
|
||||
const before = resolveId(input)
|
||||
const cleared = {
|
||||
...input,
|
||||
contextClearOperationId: 'clear-1',
|
||||
submissions: [
|
||||
{
|
||||
clientMessageId: before.operationId,
|
||||
dispatchState: 'pending' as const,
|
||||
submittedAt: 1_000
|
||||
}
|
||||
]
|
||||
}
|
||||
const fresh = resolveId(cleared)
|
||||
expect(fresh.operationId).not.toBe(before.operationId)
|
||||
expect(resolveId(cleared).operationId).toBe(fresh.operationId)
|
||||
})
|
||||
|
||||
it('re-mints when the batch grows', () => {
|
||||
const db = fakeDb()
|
||||
const first = resolveId({
|
||||
|
||||
@@ -82,10 +82,17 @@ export function mintAgentSessionOperationId(now: number): string {
|
||||
/** Batch identity, and the only thing reuse may be keyed on. */
|
||||
export function structuredPointerBatchFingerprint(
|
||||
sessionId: string,
|
||||
messageIds: readonly string[]
|
||||
messageIds: readonly string[],
|
||||
contextClearOperationId?: string
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([sessionId, messageIds]))
|
||||
.update(
|
||||
JSON.stringify(
|
||||
contextClearOperationId
|
||||
? [sessionId, messageIds, contextClearOperationId]
|
||||
: [sessionId, messageIds]
|
||||
)
|
||||
)
|
||||
.digest('base64url')
|
||||
}
|
||||
|
||||
@@ -100,13 +107,18 @@ export function resolveStructuredPointerOperation(args: {
|
||||
sessionId: string
|
||||
/** The rows this nudge stands for; batch identity, not the body, decides reuse. */
|
||||
messageIds: readonly string[]
|
||||
contextClearOperationId?: string
|
||||
submissions: readonly StructuredPointerSubmission[]
|
||||
/** The operation id this process last sent for this mailbox, if any. */
|
||||
sentByThisProcess: string | undefined
|
||||
now?: number
|
||||
}): StructuredPointerOperation {
|
||||
const now = args.now ?? Date.now()
|
||||
const batchFingerprint = structuredPointerBatchFingerprint(args.sessionId, args.messageIds)
|
||||
const batchFingerprint = structuredPointerBatchFingerprint(
|
||||
args.sessionId,
|
||||
args.messageIds,
|
||||
args.contextClearOperationId
|
||||
)
|
||||
const stored = args.db.getStructuredPointerOperation(args.mailboxHandle)
|
||||
const attempt = decideStructuredPointerAttempt({
|
||||
row: stored,
|
||||
|
||||
@@ -118,17 +118,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
|
||||
await ensureHostInstalled(ctx)
|
||||
const host = requireSessionHost(ctx, params.envelope.sessionId)
|
||||
await host.revealSession(params.envelope.sessionId)
|
||||
const result = await host.conversationCommand(callerFor(ctx), { ...params, userSend: true })
|
||||
if (result.ok && result.value.command === 'clear' && result.value.replacementSessionId) {
|
||||
const replacement = host
|
||||
.conversationReplacements()
|
||||
.find((entry) => entry.sourceSessionId === params.envelope.sessionId)
|
||||
if (replacement) {
|
||||
ctx.runtime.replaceStructuredAgentSessionTab(replacement)
|
||||
}
|
||||
await host.close(params.envelope.sessionId, 'user-close')
|
||||
}
|
||||
return result
|
||||
return host.conversationCommand(callerFor(ctx), { ...params, userSend: true })
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
/**
|
||||
* A chat tab's pointer to the conversation it shows, driven end to end: a real record store on disk,
|
||||
* the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, vi } from 'vitest'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
|
||||
import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import {
|
||||
HOST_TEST_LOCATION,
|
||||
HOST_TEST_NOW,
|
||||
HOST_TEST_SESSION,
|
||||
hostTestAttachParams,
|
||||
hostTestMessage,
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
||||
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type { AgentSessionRecordStore } from '../../agent-session-record-store'
|
||||
import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options'
|
||||
import { SESSION_TAB_METHODS } from './session-tabs'
|
||||
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
|
||||
import { commitStructuredAgentSessionCreate } from './structured-agent-session-create'
|
||||
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'
|
||||
|
||||
export const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}`
|
||||
export const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}`
|
||||
export const caller = { callerKey: 'trusted-local:runtime' }
|
||||
|
||||
export let directory: string
|
||||
export let store: AgentSessionRecordStore
|
||||
export let host: StructuredAgentSessionHost
|
||||
export let runtime: OrcaRuntimeService
|
||||
export let dispatcher: RpcDispatcher
|
||||
export let acquisitions = 0
|
||||
let acquireFails = false
|
||||
|
||||
export function setAcquireFailure(fails: boolean): void {
|
||||
acquireFails = fails
|
||||
}
|
||||
export let closeSession: ReturnType<typeof vi.fn<() => Promise<boolean>>>
|
||||
|
||||
function providerAdapter(): StructuredAgentSessionAdapter {
|
||||
return {
|
||||
supportsLocation: (location) =>
|
||||
location.executionHostId === 'local' && location.wslDistro === null,
|
||||
acquire: vi.fn(async (input) => {
|
||||
if (acquireFails) {
|
||||
throw new Error('provider failed to start')
|
||||
}
|
||||
acquisitions++
|
||||
return {
|
||||
process: {
|
||||
hostId: 'local',
|
||||
pid: 4000 + acquisitions,
|
||||
processStartTimeMs: HOST_TEST_NOW,
|
||||
spawnToken: input.spawnToken
|
||||
},
|
||||
link: {
|
||||
linkId: `link-${acquisitions}`,
|
||||
mintedAtFence: input.fence,
|
||||
observedAt: HOST_TEST_NOW,
|
||||
origin: 'created' as const,
|
||||
handle: codexProviderHandle(
|
||||
`00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
dispatch: vi.fn(async () => ({ state: 'admitted' as const })),
|
||||
cancelTurn: vi.fn(async () => ({ cancelled: true })),
|
||||
answerPrompt: async () => {},
|
||||
setOption: async () => {},
|
||||
releaseAcquisition: async () => true,
|
||||
closeSession,
|
||||
readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } })
|
||||
}
|
||||
}
|
||||
|
||||
export async function openHost(): Promise<void> {
|
||||
store = await openTestAgentSessionRecordStore(directory)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: providerAdapter(),
|
||||
journalDatabase: openTestJournalHostDatabase(directory),
|
||||
claimKeyId: 'key',
|
||||
now: () => HOST_TEST_NOW,
|
||||
mintSpawnToken: () => `spawn-${acquisitions}`
|
||||
})
|
||||
setStructuredAgentSessionHost(host)
|
||||
}
|
||||
|
||||
type CallResponse = {
|
||||
ok: boolean
|
||||
result?: { ok?: boolean; value?: { replacementSessionId?: string } }
|
||||
}
|
||||
|
||||
export async function call(
|
||||
method: string,
|
||||
params: unknown,
|
||||
context: RpcDispatchStreamingOptions = {}
|
||||
): Promise<CallResponse> {
|
||||
const response = await dispatcher.dispatch(
|
||||
{ id: 'request', authToken: 'token', method, params },
|
||||
context
|
||||
)
|
||||
return JSON.parse(JSON.stringify(response))
|
||||
}
|
||||
|
||||
export async function createChat(sessionId: string, tabId?: string) {
|
||||
return commitStructuredAgentSessionCreate({
|
||||
runtime,
|
||||
caller,
|
||||
activate: true,
|
||||
prepared: {
|
||||
host,
|
||||
attachParams: hostTestAttachParams(null, {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: ''
|
||||
},
|
||||
...(tabId ? { surfaceTabId: tabId } : {})
|
||||
}),
|
||||
tab: { workspaceId: HOST_TEST_LOCATION.workspaceId, agent: 'codex' }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function envelopeFor(method: string, sessionId: string, fields: Record<string, unknown>) {
|
||||
return {
|
||||
sessionId,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields })
|
||||
}
|
||||
}
|
||||
|
||||
export async function clear(
|
||||
sessionId: string,
|
||||
context: RpcDispatchStreamingOptions = {}
|
||||
): Promise<string> {
|
||||
const response = await call(
|
||||
'agentSession.conversationCommand',
|
||||
{
|
||||
command: 'clear',
|
||||
envelope: envelopeFor('agentSession.conversationCommand', sessionId, { command: 'clear' })
|
||||
},
|
||||
context
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
const replacement = response.result?.value?.replacementSessionId
|
||||
expect(replacement).toBeUndefined()
|
||||
return sessionId
|
||||
}
|
||||
|
||||
export async function send(sessionId: string, text: string) {
|
||||
const body = hostTestMessage(text)
|
||||
return call('agentSession.send', {
|
||||
body,
|
||||
envelope: envelopeFor('agentSession.send', sessionId, { body })
|
||||
})
|
||||
}
|
||||
|
||||
export async function snapshot() {
|
||||
return runtime.listMobileSessionTabs(WORKTREE)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetHostTestOperationIds()
|
||||
acquisitions = 0
|
||||
acquireFails = false
|
||||
closeSession = vi.fn(async () => true)
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-chat-tab-table-'))
|
||||
runtime = new OrcaRuntimeService()
|
||||
vi.spyOn(runtime, 'getClientSettings').mockReturnValue(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the structured-chat policy reads only this one setting on these paths.
|
||||
{ experimentalNativeChat: true } as ReturnType<OrcaRuntimeService['getClientSettings']>
|
||||
)
|
||||
dispatcher = new RpcDispatcher({
|
||||
runtime,
|
||||
methods: [...STRUCTURED_AGENT_SESSION_METHODS, ...SESSION_TAB_METHODS]
|
||||
})
|
||||
await openHost()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await host?.flushAllStreamedEvents()
|
||||
setStructuredAgentSessionHost(null)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
@@ -1,425 +1,228 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
/**
|
||||
* A chat tab's pointer to the conversation it shows, driven end to end: a real record store on disk,
|
||||
* the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
|
||||
import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import {
|
||||
HOST_TEST_LOCATION,
|
||||
HOST_TEST_NOW,
|
||||
HOST_TEST_SESSION,
|
||||
hostTestAttachParams,
|
||||
hostTestMessage,
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
hostTestOperationId
|
||||
} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
||||
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type { AgentSessionRecordStore } from '../../agent-session-record-store'
|
||||
import {
|
||||
openTestAgentSessionRecordStore,
|
||||
seedTestAgentSessionStoreFromNewerBuild
|
||||
} from '../../agent-session-record-store-test-harness'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options'
|
||||
import { seedTestAgentSessionStoreFromNewerBuild } from '../../agent-session-record-store-test-harness'
|
||||
import { OrcaRuntimeRpcServer } from '../../runtime-rpc'
|
||||
import { DeviceRegistry } from '../../device-registry'
|
||||
import WebSocket from 'ws'
|
||||
import { SESSION_TAB_METHODS } from './session-tabs'
|
||||
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
|
||||
import { commitStructuredAgentSessionCreate } from './structured-agent-session-create'
|
||||
import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close'
|
||||
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 WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}`
|
||||
const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}`
|
||||
const caller = { callerKey: 'trusted-local:runtime' }
|
||||
|
||||
let directory: string
|
||||
let store: AgentSessionRecordStore
|
||||
let host: StructuredAgentSessionHost
|
||||
let runtime: OrcaRuntimeService
|
||||
let dispatcher: RpcDispatcher
|
||||
let acquisitions = 0
|
||||
let acquireFails = false
|
||||
let closeSession: ReturnType<typeof vi.fn<() => Promise<boolean>>>
|
||||
|
||||
function providerAdapter(): StructuredAgentSessionAdapter {
|
||||
return {
|
||||
supportsLocation: (location) =>
|
||||
location.executionHostId === 'local' && location.wslDistro === null,
|
||||
acquire: vi.fn(async (input) => {
|
||||
if (acquireFails) {
|
||||
throw new Error('provider failed to start')
|
||||
}
|
||||
acquisitions++
|
||||
return {
|
||||
process: {
|
||||
hostId: 'local',
|
||||
pid: 4000 + acquisitions,
|
||||
processStartTimeMs: HOST_TEST_NOW,
|
||||
spawnToken: input.spawnToken
|
||||
},
|
||||
link: {
|
||||
linkId: `link-${acquisitions}`,
|
||||
mintedAtFence: input.fence,
|
||||
observedAt: HOST_TEST_NOW,
|
||||
origin: 'created' as const,
|
||||
handle: codexProviderHandle(
|
||||
`00000000-0000-4000-8000-${String(acquisitions).padStart(12, '0')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
dispatch: vi.fn(async () => ({ state: 'admitted' as const })),
|
||||
cancelTurn: vi.fn(async () => ({ cancelled: true })),
|
||||
answerPrompt: async () => {},
|
||||
setOption: async () => {},
|
||||
releaseAcquisition: async () => true,
|
||||
closeSession,
|
||||
readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } })
|
||||
}
|
||||
}
|
||||
|
||||
async function openHost(): Promise<void> {
|
||||
store = await openTestAgentSessionRecordStore(directory)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: providerAdapter(),
|
||||
journalDatabase: openTestJournalHostDatabase(directory),
|
||||
claimKeyId: 'key',
|
||||
now: () => HOST_TEST_NOW,
|
||||
mintSpawnToken: () => `spawn-${acquisitions}`
|
||||
})
|
||||
setStructuredAgentSessionHost(host)
|
||||
}
|
||||
|
||||
type CallResponse = {
|
||||
ok: boolean
|
||||
result?: { ok?: boolean; value?: { replacementSessionId?: string } }
|
||||
}
|
||||
|
||||
async function call(
|
||||
method: string,
|
||||
params: unknown,
|
||||
context: RpcDispatchStreamingOptions = {}
|
||||
): Promise<CallResponse> {
|
||||
const response = await dispatcher.dispatch(
|
||||
{ id: 'request', authToken: 'token', method, params },
|
||||
context
|
||||
)
|
||||
return JSON.parse(JSON.stringify(response))
|
||||
}
|
||||
|
||||
async function createChat(sessionId: string, tabId?: string) {
|
||||
return commitStructuredAgentSessionCreate({
|
||||
runtime,
|
||||
caller,
|
||||
activate: true,
|
||||
prepared: {
|
||||
host,
|
||||
attachParams: hostTestAttachParams(null, {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: ''
|
||||
},
|
||||
...(tabId ? { surfaceTabId: tabId } : {})
|
||||
}),
|
||||
tab: { workspaceId: HOST_TEST_LOCATION.workspaceId, agent: 'codex' }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function envelopeFor(method: string, sessionId: string, fields: Record<string, unknown>) {
|
||||
return {
|
||||
sessionId,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields })
|
||||
}
|
||||
}
|
||||
|
||||
async function clear(sessionId: string): Promise<string> {
|
||||
const response = await call('agentSession.conversationCommand', {
|
||||
command: 'clear',
|
||||
envelope: envelopeFor('agentSession.conversationCommand', sessionId, { command: 'clear' })
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
||||
const replacement = response.result?.value?.replacementSessionId
|
||||
expect(replacement).toBeDefined()
|
||||
return replacement!
|
||||
}
|
||||
|
||||
async function send(sessionId: string, text: string) {
|
||||
const body = hostTestMessage(text)
|
||||
return call('agentSession.send', {
|
||||
body,
|
||||
envelope: envelopeFor('agentSession.send', sessionId, { body })
|
||||
})
|
||||
}
|
||||
|
||||
async function snapshot() {
|
||||
return runtime.listMobileSessionTabs(WORKTREE)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetHostTestOperationIds()
|
||||
acquisitions = 0
|
||||
acquireFails = false
|
||||
closeSession = vi.fn(async () => true)
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-chat-tab-table-'))
|
||||
runtime = new OrcaRuntimeService()
|
||||
vi.spyOn(runtime, 'getClientSettings').mockReturnValue(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the structured-chat policy reads only this one setting on these paths.
|
||||
{ experimentalNativeChat: true } as ReturnType<OrcaRuntimeService['getClientSettings']>
|
||||
)
|
||||
dispatcher = new RpcDispatcher({
|
||||
runtime,
|
||||
methods: [...STRUCTURED_AGENT_SESSION_METHODS, ...SESSION_TAB_METHODS]
|
||||
})
|
||||
await openHost()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await host?.flushAllStreamedEvents()
|
||||
setStructuredAgentSessionHost(null)
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
import {
|
||||
WORKTREE,
|
||||
SOURCE_TAB,
|
||||
caller,
|
||||
directory,
|
||||
store,
|
||||
host,
|
||||
runtime,
|
||||
dispatcher,
|
||||
acquisitions,
|
||||
closeSession,
|
||||
openHost,
|
||||
call,
|
||||
createChat,
|
||||
envelopeFor,
|
||||
clear,
|
||||
send,
|
||||
snapshot,
|
||||
setAcquireFailure
|
||||
} from './structured-chat-tab-table.test-fixture'
|
||||
|
||||
describe('a chat tab across /clear', () => {
|
||||
it('keeps sending through a second and third /clear, the tab following each replacement', async () => {
|
||||
it('keeps one conversation and published tab through repeated clears and a new send', async () => {
|
||||
expect(await createChat(HOST_TEST_SESSION)).toMatchObject({ ok: true })
|
||||
expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB)
|
||||
let current = HOST_TEST_SESSION
|
||||
// A pending send blocks /clear by design, so the clears run back to back and the chat sends after.
|
||||
const before = await snapshot()
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const replacement = await clear(current)
|
||||
expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB)
|
||||
expect(store.getSessionTabId(current)).toBeNull()
|
||||
current = replacement
|
||||
expect(await clear(HOST_TEST_SESSION)).toBe(HOST_TEST_SESSION)
|
||||
expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB)
|
||||
const published = await snapshot()
|
||||
expect(published.tabs.map((tab) => tab.id)).toEqual(before.tabs.map((tab) => tab.id))
|
||||
expect(published.tabGroups).toEqual(before.tabGroups)
|
||||
expect(published.activeTabId).toBe(before.activeTabId)
|
||||
}
|
||||
expect(await send(current, 'after three clears')).toMatchObject({
|
||||
expect(await send(HOST_TEST_SESSION, 'after three clears')).toMatchObject({
|
||||
ok: true,
|
||||
result: { ok: true }
|
||||
})
|
||||
const tabs = (await snapshot()).tabs
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(tabs[0]).toMatchObject({ type: 'agent-session', sessionId: current })
|
||||
expect(store.listVisibleSessionIds()).toEqual([current])
|
||||
expect((await snapshot()).tabs).toHaveLength(1)
|
||||
expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION])
|
||||
expect(store.listRecords()).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Each press carries its own operation id, so a later /clear is a new call the host answers.
|
||||
it('answers a /clear pressed again after it committed with that clear, starting nothing', async () => {
|
||||
it('records another clear while idle without starting or renaming the chat', async () => {
|
||||
await createChat(HOST_TEST_SESSION)
|
||||
const replacement = await clear(HOST_TEST_SESSION)
|
||||
await clear(HOST_TEST_SESSION)
|
||||
const first = store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary
|
||||
const started = acquisitions
|
||||
expect(await clear(HOST_TEST_SESSION)).toBe(replacement)
|
||||
await clear(HOST_TEST_SESSION)
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).not.toEqual(first)
|
||||
expect(acquisitions).toBe(started)
|
||||
expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([`agent-session:${replacement}`])
|
||||
expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([
|
||||
`agent-session:${HOST_TEST_SESSION}`
|
||||
])
|
||||
})
|
||||
|
||||
it('answers a retyped /clear whose answer was lost with that clear, the tab on its replacement', async () => {
|
||||
it('replays a lost answer with the same tab and one divider', async () => {
|
||||
await createChat(HOST_TEST_SESSION)
|
||||
const persist = store.recordOperationOutcome.bind(store)
|
||||
let lost = false
|
||||
vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => {
|
||||
if (!lost && input.outcome.status === 'succeeded' && input.outcome.conversationCommand) {
|
||||
lost = true
|
||||
throw new Error('connection lost')
|
||||
}
|
||||
return persist(input)
|
||||
const journal = host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)!.journal
|
||||
const original = journal.context.clear.bind(journal.context)
|
||||
const lost = vi.spyOn(journal.context, 'clear').mockImplementationOnce(async (...args) => {
|
||||
await original(...args)
|
||||
throw new Error('lost acknowledgement')
|
||||
})
|
||||
const lostAnswer = await call('agentSession.conversationCommand', {
|
||||
const params = {
|
||||
command: 'clear',
|
||||
envelope: envelopeFor('agentSession.conversationCommand', HOST_TEST_SESSION, {
|
||||
command: 'clear'
|
||||
})
|
||||
})
|
||||
expect(lostAnswer.result?.ok).not.toBe(true)
|
||||
const started = acquisitions
|
||||
const replacement = await clear(HOST_TEST_SESSION)
|
||||
expect(acquisitions).toBe(started)
|
||||
expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB)
|
||||
expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([`agent-session:${replacement}`])
|
||||
expect(await send(replacement, 'after the retry')).toMatchObject({
|
||||
}
|
||||
expect((await call('agentSession.conversationCommand', params)).result?.ok).not.toBe(true)
|
||||
lost.mockRestore()
|
||||
const landed = journal.snapshot()
|
||||
expect(await call('agentSession.conversationCommand', params)).toMatchObject({
|
||||
ok: true,
|
||||
result: { ok: true }
|
||||
})
|
||||
expect(journal.snapshot()).toEqual(landed)
|
||||
expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([
|
||||
`agent-session:${HOST_TEST_SESSION}`
|
||||
])
|
||||
})
|
||||
|
||||
it('reveals a cleared conversation in its own tab without activating the current chat', async () => {
|
||||
it('reveals the same tab from history instead of opening another conversation', async () => {
|
||||
await createChat(HOST_TEST_SESSION)
|
||||
const replacement = await clear(HOST_TEST_SESSION)
|
||||
|
||||
await clear(HOST_TEST_SESSION)
|
||||
expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
|
||||
const revealedTabId = store.getSessionTabId(HOST_TEST_SESSION)
|
||||
expect(revealedTabId).not.toBeNull()
|
||||
expect(revealedTabId).not.toBe(SOURCE_TAB)
|
||||
expect(revealedTabId).not.toContain(':')
|
||||
expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB)
|
||||
const published = await snapshot()
|
||||
expect(published.tabs.map((tab) => tab.id)).toEqual([
|
||||
`agent-session:${replacement}`,
|
||||
expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB)
|
||||
expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([
|
||||
`agent-session:${HOST_TEST_SESSION}`
|
||||
])
|
||||
expect(published.activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`)
|
||||
})
|
||||
|
||||
it('closes the cleared conversation and leaves the current chat and its tab', async () => {
|
||||
it('closes the same cleared chat as settled before its next provider start', async () => {
|
||||
await createChat(HOST_TEST_SESSION)
|
||||
const replacement = await clear(HOST_TEST_SESSION)
|
||||
await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })
|
||||
|
||||
expect(
|
||||
await call('session.tabs.close', {
|
||||
worktree: WORKTREE,
|
||||
tabId: `agent-session:${HOST_TEST_SESSION}`,
|
||||
reason: 'user'
|
||||
})
|
||||
).toMatchObject({ ok: true })
|
||||
|
||||
expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull()
|
||||
expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB)
|
||||
expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual([`agent-session:${replacement}`])
|
||||
expect(await send(replacement, 'still here')).toMatchObject({ ok: true, result: { ok: true } })
|
||||
})
|
||||
|
||||
// A worktree delete closes every chat in it; one that never started must not ask to be forced.
|
||||
it('closes a replacement that never started as settled', async () => {
|
||||
await createChat(HOST_TEST_SESSION)
|
||||
const replacement = await clear(HOST_TEST_SESSION)
|
||||
expect(await closeStructuredAgentSessionChild(replacement)).toEqual({
|
||||
await clear(HOST_TEST_SESSION)
|
||||
expect(await closeStructuredAgentSessionChild(HOST_TEST_SESSION)).toEqual({
|
||||
stopped: true,
|
||||
closeAttempted: true
|
||||
})
|
||||
expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull()
|
||||
})
|
||||
|
||||
it('puts a cleared chat back under the tab id it had when its close does not land', async () => {
|
||||
it('keeps the original tab when closing the new context is unverifiable', async () => {
|
||||
await createChat(HOST_TEST_SESSION)
|
||||
const replacement = await clear(HOST_TEST_SESSION)
|
||||
// Its agent starts on its first message; only a running agent has a close that can fail.
|
||||
expect(await send(replacement, 'first message')).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(store.getRecord(replacement)?.lease.claimStatus).toBe('live'))
|
||||
closeSession.mockResolvedValue(false)
|
||||
|
||||
const outcome = await closeStructuredAgentSessionChild(replacement)
|
||||
expect(outcome).toMatchObject({ stopped: false })
|
||||
expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB)
|
||||
closeSession.mockResolvedValue(true)
|
||||
await clear(HOST_TEST_SESSION)
|
||||
expect(await send(HOST_TEST_SESSION, 'new context')).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus).toBe('live')
|
||||
)
|
||||
closeSession.mockResolvedValueOnce(false)
|
||||
expect(await closeStructuredAgentSessionChild(HOST_TEST_SESSION)).toMatchObject({
|
||||
stopped: false
|
||||
})
|
||||
expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB)
|
||||
})
|
||||
|
||||
it('keeps the tab id and its pointer across a restart', async () => {
|
||||
it('keeps the same tab and conversation across restart', async () => {
|
||||
await createChat(HOST_TEST_SESSION)
|
||||
const replacement = await clear(await clear(HOST_TEST_SESSION))
|
||||
await clear(HOST_TEST_SESSION)
|
||||
await host.flushAllStreamedEvents()
|
||||
|
||||
await openHost()
|
||||
expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB)
|
||||
expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(SOURCE_TAB)
|
||||
expect(host.getPersistedVisibleSessionTabIndex()).toEqual({
|
||||
present: true,
|
||||
sessionIds: [replacement]
|
||||
sessionIds: [HOST_TEST_SESSION]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('other clients after a /clear from this window', () => {
|
||||
const STRUCTURED = [
|
||||
describe('other clients after a clear', () => {
|
||||
const capabilities = [
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
]
|
||||
const clients = {
|
||||
window: { clientKind: 'runtime' as const, clientCapabilities: STRUCTURED },
|
||||
phone: { clientKind: 'mobile' as const, clientCapabilities: STRUCTURED }
|
||||
}
|
||||
|
||||
/** The chat tabs a client lists, as that client is sent them. */
|
||||
async function listedChats(context: RpcDispatchStreamingOptions) {
|
||||
const response = await dispatcher.dispatch(
|
||||
{
|
||||
id: 'list',
|
||||
authToken: 'token',
|
||||
method: 'session.tabs.list',
|
||||
params: { worktree: WORKTREE }
|
||||
},
|
||||
context
|
||||
)
|
||||
const listed: { result?: { tabs?: { type: string }[] } } = JSON.parse(JSON.stringify(response))
|
||||
return (listed.result?.tabs ?? []).filter((tab) => tab.type === 'agent-session')
|
||||
}
|
||||
|
||||
/** The session list each client keeps: the latest status the host published per session. */
|
||||
function sessionList(id: string): Map<string, AgentSessionStatusSummary | null> {
|
||||
const latest = new Map<string, AgentSessionStatusSummary | null>()
|
||||
host.subscribeStatus({
|
||||
id,
|
||||
emit: (event) => {
|
||||
if (event.type === 'snapshot') {
|
||||
event.sessions.forEach((session) => latest.set(session.sessionId, session))
|
||||
} else if (event.type === 'status') {
|
||||
latest.set(event.session.sessionId, event.session)
|
||||
}
|
||||
}
|
||||
})
|
||||
return latest
|
||||
}
|
||||
|
||||
it('shows another window and a phone the replacement the marker names, with nothing unread', async () => {
|
||||
it('clears through the authenticated mobile WebSocket allowlist without replacing the tab', async () => {
|
||||
await createChat(HOST_TEST_SESSION)
|
||||
const lists = { window: sessionList('window-b'), phone: sessionList('phone') }
|
||||
const replacement = await clear(HOST_TEST_SESSION)
|
||||
// The stored pointer is the only source of the id: nothing a client holds can derive it.
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toMatchObject({
|
||||
phase: 'committed',
|
||||
replacementSessionId: replacement
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath: directory,
|
||||
enableWebSocket: false,
|
||||
methods: [...STRUCTURED_AGENT_SESSION_METHODS, ...SESSION_TAB_METHODS]
|
||||
})
|
||||
expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB)
|
||||
|
||||
for (const context of Object.values(clients)) {
|
||||
expect(await listedChats(context)).toEqual([
|
||||
expect.objectContaining({ sessionId: replacement, replacesSessionId: HOST_TEST_SESSION })
|
||||
])
|
||||
}
|
||||
// Never started, so no client lists a turn for it to be unread.
|
||||
for (const list of Object.values(lists)) {
|
||||
expect(list.get(replacement)?.status ?? null).toBeNull()
|
||||
expect(list.get(replacement)?.turnOutcome).toBeUndefined()
|
||||
}
|
||||
|
||||
expect(await send(replacement, 'first message')).toMatchObject({
|
||||
ok: true,
|
||||
result: { ok: true }
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
for (const list of Object.values(lists)) {
|
||||
expect(list.get(replacement)).toMatchObject({ sessionId: replacement, status: 'working' })
|
||||
server['deviceRegistry'] = new DeviceRegistry(directory)
|
||||
const phone = server['deviceRegistry'].addDevice('phone', 'mobile')
|
||||
const socket = WebSocket.prototype
|
||||
const replies: unknown[] = []
|
||||
const before = await snapshot()
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'mobile-clear',
|
||||
method: 'agentSession.conversationCommand',
|
||||
deviceToken: phone.token,
|
||||
params: {
|
||||
command: 'clear',
|
||||
envelope: envelopeFor('agentSession.conversationCommand', HOST_TEST_SESSION, {
|
||||
command: 'clear'
|
||||
})
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response)),
|
||||
() => undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
phone.token,
|
||||
{
|
||||
ws: socket,
|
||||
connectionId: 'phone-connection',
|
||||
device: { deviceId: phone.deviceId, deviceToken: phone.token, scope: 'mobile' },
|
||||
clientCapabilities: capabilities,
|
||||
transport: { transport: 'direct' }
|
||||
}
|
||||
})
|
||||
for (const context of Object.values(clients)) {
|
||||
expect(await listedChats(context)).toEqual([
|
||||
expect.objectContaining({ sessionId: replacement })
|
||||
])
|
||||
}
|
||||
)
|
||||
expect(replies).toMatchObject([
|
||||
{ ok: true, result: { ok: true, value: { command: 'clear', state: 'completed' } } }
|
||||
])
|
||||
expect((await snapshot()).tabs.map((tab) => tab.id)).toEqual(before.tabs.map((tab) => tab.id))
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.providerContextBoundary).toBeDefined()
|
||||
})
|
||||
|
||||
it.each(['runtime', 'mobile'] as const)(
|
||||
'keeps the published identity for a %s caller and a reconnecting reader',
|
||||
async (clientKind) => {
|
||||
await createChat(HOST_TEST_SESSION)
|
||||
const before = await snapshot()
|
||||
await clear(HOST_TEST_SESSION, { clientKind, clientCapabilities: capabilities })
|
||||
const listed = await dispatcher.dispatch(
|
||||
{
|
||||
id: 'reconnected',
|
||||
authToken: 'token',
|
||||
method: 'session.tabs.list',
|
||||
params: { worktree: WORKTREE }
|
||||
},
|
||||
{ clientKind, clientCapabilities: capabilities }
|
||||
)
|
||||
expect(listed).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
activeTabId: before.activeTabId,
|
||||
tabs: [{ id: `agent-session:${HOST_TEST_SESSION}`, sessionId: HOST_TEST_SESSION }]
|
||||
}
|
||||
})
|
||||
const current = await snapshot()
|
||||
expect(current.tabGroups).toEqual(before.tabGroups)
|
||||
expect(current.tabs[0]).not.toHaveProperty('replacesSessionId')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('session tab mutations from other clients, unchanged by the table', () => {
|
||||
@@ -512,12 +315,12 @@ describe('a create that reserves its tab', () => {
|
||||
})
|
||||
|
||||
it('leaves no tab behind when the create fails, so nothing is restored and the id is free', async () => {
|
||||
acquireFails = true
|
||||
setAcquireFailure(true)
|
||||
expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ ok: false })
|
||||
expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull()
|
||||
expect(store.listVisibleSessionIds()).toEqual([])
|
||||
|
||||
acquireFails = false
|
||||
setAcquireFailure(false)
|
||||
expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({
|
||||
ok: true,
|
||||
value: { tabId: 'reserved-tab' }
|
||||
|
||||
@@ -272,7 +272,11 @@ describe('restoring the chat tabs open at quit', () => {
|
||||
`agent-session:${CHAT_A}`,
|
||||
`agent-session:${CHAT_B}`
|
||||
])
|
||||
expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED })
|
||||
expect(
|
||||
published().every(
|
||||
(tab) => tab.type !== 'agent-session' || tab.replacesSessionId === undefined
|
||||
)
|
||||
).toBe(true)
|
||||
// A newer Orca's leases are adjudicated in memory, so nothing fails there; its chats do not open.
|
||||
if (newer) {
|
||||
for (const sessionId of [CHAT_A, CHAT_B]) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
|
||||
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
|
||||
import { activeProviderContext } from '../../shared/agent-session-provider-context'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { localOrchestrationCliCommand } from './orchestration/cli-command'
|
||||
@@ -92,10 +92,9 @@ export async function openChat(sessionId: string): Promise<FakeConnection> {
|
||||
export const threadBySession = new Map<string, string>()
|
||||
|
||||
export function connectionFor(sessionId: string): FakeConnection {
|
||||
// A cleared chat's successor starts on its first message; its record then names its thread.
|
||||
const head = agentSessionProviderHandleChainHead(
|
||||
host.deps.store.getRecord(sessionId)?.providerHandleChain ?? []
|
||||
)
|
||||
// A cleared context starts on its first message; its record then names its new thread.
|
||||
const record = host.deps.store.getRecord(sessionId)
|
||||
const head = record ? activeProviderContext(record).head : null
|
||||
const thread = threadBySession.get(sessionId) ?? head?.handle.nativeId
|
||||
const connection = codex.connections.findLast((candidate) => candidate.threadId === thread)
|
||||
if (!connection) {
|
||||
@@ -206,7 +205,7 @@ export async function coordinatorRunAndTask(): Promise<{ runId: string; taskId:
|
||||
return { runId, taskId: idOf(task.task) }
|
||||
}
|
||||
|
||||
/** `/clear` as the chat surface runs it: the conversation continues in a new session. */
|
||||
/** `/clear` as the chat surface runs it: the same conversation gets a fresh context. */
|
||||
export async function clearChat(sessionId: string): Promise<string> {
|
||||
const command = 'clear' as const
|
||||
const cleared = await host.conversationCommand(
|
||||
@@ -225,21 +224,21 @@ export async function clearChat(sessionId: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
)
|
||||
const successor = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
if (!successor) {
|
||||
expect(cleared, JSON.stringify(cleared)).toMatchObject({ ok: true })
|
||||
if (!cleared.ok) {
|
||||
throw new Error(`clear failed: ${JSON.stringify(cleared)}`)
|
||||
}
|
||||
// The surface swaps the tab over to the session that continues the chat.
|
||||
await host.setSessionTabVisibility(sessionId, false)
|
||||
await host.setSessionTabVisibility(successor, true)
|
||||
return successor
|
||||
expect(cleared.value.replacementSessionId).toBeUndefined()
|
||||
expect(host.deps.store.getVisibleSessionTabIndex().sessionIds).toContain(sessionId)
|
||||
threadBySession.delete(sessionId)
|
||||
return sessionId
|
||||
}
|
||||
|
||||
/** A cleared chat's successor runs once the user writes to it; only then can its agent act. */
|
||||
export async function startSuccessor(successor: string): Promise<void> {
|
||||
expect(await sendUserMessage(successor, 'hello')).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT)
|
||||
await settleTurn(successor, 0)
|
||||
/** A cleared context starts when the user writes to the same conversation. */
|
||||
export async function startClearedContext(sessionId: string): Promise<void> {
|
||||
expect(await sendUserMessage(sessionId, 'hello')).toMatchObject({ ok: true })
|
||||
await vi.waitFor(() => expect(connectionFor(sessionId).turns).toHaveLength(1), WAIT)
|
||||
await settleTurn(sessionId, 0)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
finishWorker,
|
||||
coordinatorRunAndTask,
|
||||
clearChat,
|
||||
startSuccessor,
|
||||
startClearedContext,
|
||||
WAIT,
|
||||
POINTER,
|
||||
ptyPointer,
|
||||
@@ -489,37 +489,38 @@ describe('a worker result reaches the structured chat that coordinates it', () =
|
||||
})
|
||||
|
||||
describe('a /clear keeps the chat its orchestration address', () => {
|
||||
it("delivers the conversation's Run to the session that continues it, and acts as it", async () => {
|
||||
it("delivers the conversation's Run to its fresh context under the same address", async () => {
|
||||
await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
const generation = db.getRunRaw(runId)!.consumer_generation
|
||||
const successor = await clearChat(COORDINATOR)
|
||||
const cleared = await clearChat(COORDINATOR)
|
||||
expect(cleared).toBe(COORDINATOR)
|
||||
const opened = codex.connections.length
|
||||
|
||||
// The worker's result is the successor's first message, which starts it on a new thread.
|
||||
// The worker's result starts the fresh context on a new thread.
|
||||
await finishWorker(taskId)
|
||||
await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT)
|
||||
const next = connectionFor(successor)
|
||||
await vi.waitFor(() => expect(connectionFor(cleared).turns).toHaveLength(1), WAIT)
|
||||
const next = connectionFor(cleared)
|
||||
expect(codex.connections.slice(opened)).toEqual([next])
|
||||
expect(next.methods.filter((method) => method.startsWith('thread/'))).toEqual(['thread/start'])
|
||||
expect(next.turns[0]!.text).toMatch(POINTER)
|
||||
await settleTurn(successor, 0)
|
||||
await settleTurn(cleared, 0)
|
||||
await expect(
|
||||
call('orchestration.runCurrent', {}, { sessionId: successor })
|
||||
call('orchestration.runCurrent', {}, { sessionId: cleared })
|
||||
).resolves.toMatchObject({ run: { id: runId } })
|
||||
await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({
|
||||
await expect(call('orchestration.check', {}, { sessionId: cleared })).resolves.toMatchObject({
|
||||
runId,
|
||||
count: 1,
|
||||
messages: [{ type: 'worker_done' }]
|
||||
})
|
||||
// Nothing was rewritten: the Run is bound exactly as the first session bound it.
|
||||
// Clear preserves the original Run binding and consumer generation.
|
||||
expect(db.getRunRaw(runId)).toMatchObject({
|
||||
coordinator_orca_session_id: COORDINATOR,
|
||||
consumer_generation: generation
|
||||
})
|
||||
})
|
||||
|
||||
it('points mail the cleared chat never took at the successor, with no message from the user', async () => {
|
||||
it('points mail the earlier context never took at the fresh context, without a user message', async () => {
|
||||
const chat = await openChat(COORDINATOR)
|
||||
const { runId, taskId } = await coordinatorRunAndTask()
|
||||
await finishWorker(taskId)
|
||||
@@ -531,31 +532,32 @@ describe('a /clear keeps the chat its orchestration address', () => {
|
||||
)
|
||||
expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1)
|
||||
|
||||
const successor = await clearChat(COORDINATOR)
|
||||
await vi.waitFor(() => expect(connectionFor(successor).turns[0]?.text).toMatch(POINTER), WAIT)
|
||||
await settleTurn(successor, 0)
|
||||
const cleared = await clearChat(COORDINATOR)
|
||||
expect(cleared).toBe(COORDINATOR)
|
||||
await vi.waitFor(() => expect(connectionFor(cleared).turns[0]?.text).toMatch(POINTER), WAIT)
|
||||
await settleTurn(cleared, 0)
|
||||
await vi.waitFor(
|
||||
() => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]),
|
||||
WAIT
|
||||
)
|
||||
})
|
||||
|
||||
it("stores the successor's own Run under the conversation's address, through a chain of clears", async () => {
|
||||
it('keeps a Run under the same conversation address through repeated clears', async () => {
|
||||
await openChat(COORDINATOR)
|
||||
const middle = await clearChat(COORDINATOR)
|
||||
await startSuccessor(middle)
|
||||
const firstClear = await clearChat(COORDINATOR)
|
||||
await startClearedContext(firstClear)
|
||||
const created = await call(
|
||||
'orchestration.runCreate',
|
||||
{ objective: 'next' },
|
||||
{ sessionId: middle }
|
||||
{ sessionId: firstClear }
|
||||
)
|
||||
const runId = idOf(created.run)
|
||||
expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR)
|
||||
const successor = await clearChat(middle)
|
||||
await startSuccessor(successor)
|
||||
const cleared = await clearChat(firstClear)
|
||||
await startClearedContext(cleared)
|
||||
|
||||
await expect(
|
||||
call('orchestration.runCurrent', {}, { sessionId: successor })
|
||||
call('orchestration.runCurrent', {}, { sessionId: cleared })
|
||||
).resolves.toMatchObject({ run: { id: runId } })
|
||||
expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR)
|
||||
// What it sends carries the same address.
|
||||
@@ -564,20 +566,20 @@ describe('a /clear keeps the chat its orchestration address', () => {
|
||||
call(
|
||||
'orchestration.send',
|
||||
{ to: `orca_session_id:${PEER_CHAT}`, subject: 'hi' },
|
||||
{ sessionId: successor }
|
||||
{ sessionId: cleared }
|
||||
)
|
||||
).resolves.toMatchObject({ message: { from_handle: `orca_session_id:${COORDINATOR}` } })
|
||||
})
|
||||
|
||||
it("binds a Run a cleared chat creates or uses to the conversation's root, at the Run's current generation", async () => {
|
||||
it('binds a Run a cleared chat creates or uses to the same conversation at the current generation', async () => {
|
||||
const root = COORDINATOR
|
||||
const boundOrcaSessionId = (runId: string): string | null =>
|
||||
currentRunCoordinatorOrcaSessionId(db.getRunRaw(runId)!)
|
||||
await openChat(COORDINATOR)
|
||||
const middle = await clearChat(COORDINATOR)
|
||||
await startSuccessor(middle)
|
||||
const firstClear = await clearChat(COORDINATOR)
|
||||
await startClearedContext(firstClear)
|
||||
const first = idOf(
|
||||
(await call('orchestration.runCreate', { objective: 'first' }, { sessionId: middle })).run
|
||||
(await call('orchestration.runCreate', { objective: 'first' }, { sessionId: firstClear })).run
|
||||
)
|
||||
expect(db.getRunRaw(first)).toMatchObject({
|
||||
coordinator_orca_session_id: root,
|
||||
@@ -585,40 +587,43 @@ describe('a /clear keeps the chat its orchestration address', () => {
|
||||
})
|
||||
expect(boundOrcaSessionId(first)).toBe(root)
|
||||
const second = idOf(
|
||||
(await call('orchestration.runCreate', { objective: 'second' }, { sessionId: middle })).run
|
||||
(await call('orchestration.runCreate', { objective: 'second' }, { sessionId: firstClear }))
|
||||
.run
|
||||
)
|
||||
expect(boundOrcaSessionId(first)).toBeNull()
|
||||
|
||||
const successor = await clearChat(middle)
|
||||
await startSuccessor(successor)
|
||||
await call('orchestration.runUse', { id: first }, { sessionId: successor })
|
||||
const cleared = await clearChat(firstClear)
|
||||
await startClearedContext(cleared)
|
||||
await call('orchestration.runUse', { id: first }, { sessionId: cleared })
|
||||
const rebound = db.getRunRaw(first)!
|
||||
expect(rebound.coordinator_orca_session_id).toBe(root)
|
||||
expect(rebound.coordinator_orca_session_id_generation).toBe(rebound.consumer_generation)
|
||||
expect(boundOrcaSessionId(second)).toBeNull()
|
||||
await expect(
|
||||
call('orchestration.runCurrent', {}, { sessionId: successor })
|
||||
call('orchestration.runCurrent', {}, { sessionId: cleared })
|
||||
).resolves.toMatchObject({ run: { id: first } })
|
||||
})
|
||||
|
||||
it('lands mail sent to any session of the conversation in the live one', async () => {
|
||||
it('lands successive mail at the same conversation after repeated clears', async () => {
|
||||
await openChat(PEER_CHAT)
|
||||
const middle = await clearChat(PEER_CHAT)
|
||||
const successor = await clearChat(middle)
|
||||
// The first ping starts the live session, which neither clear did.
|
||||
for (const [index, spelling] of [PEER_CHAT, middle, successor].entries()) {
|
||||
const firstClear = await clearChat(PEER_CHAT)
|
||||
const cleared = await clearChat(firstClear)
|
||||
expect(firstClear).toBe(PEER_CHAT)
|
||||
expect(cleared).toBe(PEER_CHAT)
|
||||
// The first ping starts the fresh context, which neither clear did.
|
||||
for (const [index, spelling] of [PEER_CHAT, firstClear, cleared].entries()) {
|
||||
const sent = await call('orchestration.send', {
|
||||
from: 'term_worker',
|
||||
to: `orca_session_id:${spelling}`,
|
||||
subject: `ping ${index}`
|
||||
})
|
||||
expect(sent).toMatchObject({ message: { to_handle: `orca_session_id:${PEER_CHAT}` } })
|
||||
await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(index + 1), WAIT)
|
||||
await settleTurn(successor, index)
|
||||
await vi.waitFor(() => expect(connectionFor(cleared).turns).toHaveLength(index + 1), WAIT)
|
||||
await settleTurn(cleared, index)
|
||||
// Read each ping before the next is sent, so each check holds exactly one.
|
||||
const checked = await call('orchestration.check', {}, { sessionId: successor })
|
||||
const checked = await call('orchestration.check', {}, { sessionId: cleared })
|
||||
expect(checked).toMatchObject({ count: 1, messages: [{ subject: `ping ${index}` }] })
|
||||
await call('orchestration.check', { ack: checked.deliveryId }, { sessionId: successor })
|
||||
await call('orchestration.check', { ack: checked.deliveryId }, { sessionId: cleared })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user