refactor(agent): complete inbox lifecycle migration

This commit is contained in:
_Kerman
2026-08-03 12:25:33 +08:00
parent dc1d542092
commit 49e90695cc
214 changed files with 6019 additions and 4235 deletions

View File

@@ -390,7 +390,9 @@ export class BasicCompactService extends CompactService {
{
owner: null,
stability: 'selected-span',
flush: () => this.ctx.sessions.flush(agent.session),
flush: async () => {
await this.ctx.sessions.flush(agent.session)
},
},
operationSignal,
)

View File

@@ -141,11 +141,14 @@ function derivedText(session: Session): string[] {
}
/** Await one classified manual-compaction rejection. */
async function rejection(operation: Promise<unknown>): Promise<ManualCompactionError> {
const caught: unknown = await operation.then(
(value: unknown) => { throw new Error(`expected a rejection, resolved with ${String(value)}`) },
(error: unknown) => error,
)
async function rejection(operation: Promise<unknown> | (() => Promise<unknown>)): Promise<ManualCompactionError> {
let caught: unknown
try {
const value = await (typeof operation === 'function' ? operation() : operation)
throw new Error(`expected a rejection, resolved with ${String(value)}`)
} catch (error: unknown) {
caught = error
}
if (!(caught instanceof ManualCompactionError)) {
throw new Error(`expected a ManualCompactionError, got ${String(caught)}`)
}
@@ -195,15 +198,20 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session {
return session
}
/** A fake idle agent whose admission reservation is scripted per test. */
/** A fake idle agent whose maintenance claim is scripted per test. */
function fakeAgent(
session: Session,
reserve: () => (() => void) | undefined,
maintenanceSignal = new AbortController().signal,
): Agent {
return {
session,
options: { provider: MODEL, model: MODEL },
reserveTurnAdmission: reserve,
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
const release = reserve()
if (release === undefined) throw new Error('agent already has active work')
return task(maintenanceSignal).finally(release)
},
} as unknown as Agent
}
@@ -270,7 +278,7 @@ describe('compactNow through the real loop', () => {
expect(second.some(text => text.includes(PROMPT))).toBe(false)
})
it('keeps context injected during summarization between the markers and after the checkpoint', async () => {
it('keeps context injected during summarization pending for the next step', async () => {
const harness = await loopHarness()
const { agent, compact } = harness
await seedHistory(harness)
@@ -285,18 +293,22 @@ describe('compactNow through the real loop', () => {
expect(result).not.toBeNull()
const start = agent.session.events.findLast(event => event.type === 'compact/start')
const injected = agent.session.events.findLast(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'test')
const injected = agent.inbox.nextStep.find(message =>
message.source.kind === 'plugin' && message.source.plugin === 'test')
const end = agent.session.events.findLast(event => event.type === 'compact/end')
expect(start).toBeDefined()
expect(injected).toBeDefined()
expect(end).toBeDefined()
expect(start!.seq).toBeLessThan(injected!.seq)
expect(injected!.seq).toBeLessThan(end!.seq)
expect(result?.shadowedSeqs).not.toContain(injected?.seq)
expect(agent.session.events.some(event => event.type === 'user/message'
&& event.data.id === injected?.id)).toBe(false)
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'after compaction' }],
source: { kind: 'user' },
}))
await agent.whenIdle()
const messages = derivedText(agent.session)
expect(messages[0]).toContain('checkpoint')
expect(messages.at(-1)).toContain('INJECTED CONTEXT')
expect(messages.filter(text => text.includes('INJECTED CONTEXT'))).toHaveLength(1)
})
@@ -334,7 +346,7 @@ describe('compactNow through the real loop', () => {
content: [{ type: 'text', text: 'first in line' }],
source: { kind: 'user' },
}))
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect((await rejection(() => compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(0)
await agent.whenIdle()
@@ -400,7 +412,7 @@ describe('compactNow transaction and failure classification', () => {
session.append('compact/start', { turn: null })
const agent = fakeAgent(session, () => () => undefined)
const error = await rejection(compact.compactNow(agent, SIGNAL))
const error = await rejection(() => compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('busy')
expect(error.message).toContain('compaction lock is already active')
expect(compact.calls).toHaveLength(0)
@@ -448,7 +460,7 @@ describe('compactNow transaction and failure classification', () => {
const { compact } = detachedService()
const agent = fakeAgent(closedConversation(2), () => undefined)
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect((await rejection(() => compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(0)
})
@@ -685,7 +697,13 @@ describe('compactNow transaction and failure classification', () => {
const controller = new AbortController()
controller.abort(reason)
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
let thrown: unknown
try {
void compact.compactNow(agent, controller.signal)
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBe(reason)
expect(reserve).not.toHaveBeenCalled()
expect(measure).not.toHaveBeenCalled()
expect(compact.calls).toHaveLength(0)
@@ -713,6 +731,21 @@ describe('compactNow transaction and failure classification', () => {
.toContain('summarizer aborted')
})
it('classifies agent cancellation during maintenance as an expected cancellation', async () => {
const { compact } = detachedService()
const controller = new AbortController()
const reason = new Error('agent cancelled maintenance')
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined, controller.signal)
compact.duringSummary = () => { controller.abort(reason) }
compact.error = new Error('summarizer observed cancellation')
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('cancelled')
expect(error.cause).toBe(reason)
})
it('aborts before committing when cancellation lands after summarization', async () => {
const { compact } = detachedService()
const controller = new AbortController()