Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
#	THIRD_PARTY_NOTICES.md
#	apps/cli/composition.md
#	apps/cli/config/base.cordis.yml
#	apps/cli/package.json
#	apps/cli/src/app-cli-entry.ts
#	apps/cli/src/bin.ts
#	apps/cli/tests/args.spec.ts
#	apps/web/tests/built-boot.snapshot.ts
#	apps/web/tests/navigation-panes.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/bundle/README.i18n.yaml
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.md
#	packages/client/connection/README.zh.md
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/http-bridge.ts
#	packages/client/connection/src/index.ts
#	packages/client/connection/tests/fixture.spec.ts
#	packages/client/connection/tests/node-half.spec.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/contract/session.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.module.css
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/input/contract.ts
#	packages/client/ui-conversation/src/client/input/facade.ts
#	packages/client/ui-conversation/src/client/input/hub.ts
#	packages/client/ui-conversation/src/client/locales.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/README.zh.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/ui/tui/README.md
#	packages/ui/tui/README.zh.md
#	packages/ui/tui/src/components/content.ts
#	packages/ui/tui/src/components/transcript.ts
#	packages/ui/tui/tests/tui.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-08 01:56:29 +08:00
3973 files changed
+144864 -60887

No files matched your search

@@ -104,9 +104,9 @@ function promptInput(text: string): SummarizationInput {
/** Closed two-message turns followed by one open turn for durable compaction events. */
function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
const session = new Session(SessionId(`conversation-${turns}`))
const session = Session.create(SessionId(`conversation-${turns}`))
for (let turn = 1; turn <= turns; turn += 1) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `${text} user ${turn}` }],
source: { kind: 'user' },
@@ -135,16 +135,15 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
}
session.append('turn/start', {
turn: turns + 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
return session
}
function toolConversation(): Session {
const session = new Session(SessionId('tools'))
const session = Session.create(SessionId('tools'))
for (let turn = 1; turn <= 3; turn += 1) {
const callId = CallId(`call-${turn}`)
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `request ${turn} `.repeat(300) }],
source: { kind: 'user' },
@@ -184,15 +183,15 @@ function toolConversation(): Session {
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 4 })
return session
}
/** One closed routed tool step followed by an open turn for rewrite events. */
function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
const session = new Session(SessionId(`oversized-tool-${chars}`))
const session = Session.create(SessionId(`oversized-tool-${chars}`))
const callId = CallId('oversized')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
if (withCompactablePrompt) {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'older history '.repeat(200) }],
@@ -229,7 +228,7 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 2 })
return session
}
@@ -486,8 +485,8 @@ describe('pressure measurement and retention', () => {
it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
const compact = service(compactConfig)
const session = new Session(SessionId('headerless'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('headerless'))
session.append('turn/start', { turn: 1 })
await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL))
.resolves.toBeNull()
expect(compact.calls).toHaveLength(0)
@@ -568,9 +567,9 @@ describe('pressure measurement and retention', () => {
it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
const compact = service(compactConfig)
const session = new Session(SessionId('single-tool-pair'))
const session = Session.create(SessionId('single-tool-pair'))
const callId = CallId('single-call')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', {
header: { config: { provider: MODEL, model: MODEL } },
@@ -659,8 +658,8 @@ describe('pressure measurement and retention', () => {
it('declines when envelope pressure is high but the surface has no compactable range', async () => {
const compact = service(compactConfig)
const empty = new Session(SessionId('empty'))
empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const empty = Session.create(SessionId('empty'))
empty.append('turn/start', { turn: 1 })
empty.append('request/header', {
header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
reason: 'initial',
@@ -734,9 +733,9 @@ describe('pressure measurement and retention', () => {
it('declines when rounding a cut would consume the only tool pair', () => {
const ctx = createContext()
const session = new Session(SessionId('one-tool-pair'))
const session = Session.create(SessionId('one-tool-pair'))
const callId = CallId('only')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
@@ -875,7 +874,7 @@ describe('compaction region transaction', () => {
expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
const replay = new Session(SessionId('replay'), [...session.events])
const replay = Session.create(SessionId('replay'), [...session.events])
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
})
@@ -957,7 +956,7 @@ describe('compaction region transaction', () => {
it('rejects a session with no turn boundary at all', async () => {
const compact = service()
const session = new Session(SessionId('turnless'))
const session = Session.create(SessionId('turnless'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'orphan' }],
source: { kind: 'user' },
@@ -1077,8 +1076,8 @@ describe('compaction region transaction', () => {
it('lets a model-independent custom summarizer compact without a conversation model', async () => {
const compact = service()
const session = new Session(SessionId('model-less-region'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('model-less-region'))
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'history '.repeat(100) }],
source: { kind: 'user' },
@@ -1326,13 +1325,13 @@ describe('default one-shot summarizer', () => {
await ctx.plugin(LlmService)
void new TokenMeterService(ctx)
const compact = new ExposedCompactService(ctx, { auto: false })
await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less')))))
await expect(compact.runSummarize(promptInput('history'), agent(Session.create(SessionId('model-less')))))
.rejects.toThrow(/no provider\/model available for summarization/)
})
it('uses a complete AgentOptions target when no durable route exists', async () => {
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
const session = new Session(SessionId('headerless-summary'))
const session = Session.create(SessionId('headerless-summary'))
await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({
provider: MODEL,
@@ -1348,7 +1347,7 @@ describe('default one-shot summarizer', () => {
])('rejects incomplete AgentOptions target %#', async (options) => {
const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
const owner = {
session: new Session(SessionId(`incomplete-${String(options.model)}`)),
session: Session.create(SessionId(`incomplete-${String(options.model)}`)),
options,
} as Agent
await expect(compact.runSummarize(promptInput('history'), owner))
@@ -1421,8 +1420,11 @@ describe('default one-shot summarizer', () => {
})
describe('automatic listener and loader composition', () => {
function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise<unknown> {
return agentEvents(ctx, owner).serial('agent/step', 1, 1, signal)
function preStep(ctx: Context, owner: Agent, signal = SIGNAL) {
return agentEvents(ctx, owner).waterfall(
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
}
function recover(
@@ -1435,7 +1437,9 @@ describe('automatic listener and loader composition', () => {
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
return agentEvents(ctx, owner).waterfall(
'agent/request-error', turn, 1, error, failure, [], undefined, signal, next,
'agent/request-error',
{ turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal },
next,
).then(action => action?.kind === 'retry')
}
@@ -1443,23 +1447,23 @@ describe('automatic listener and loader composition', () => {
return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE })
}
it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => {
it('compacts before a step above threshold using the durable routed model and remains idle below it', async () => {
const ctx = createContext()
const compact = new TestCompactService(ctx, {
thresholdRatio: 0.5,
retainTokens: 180,
})
const pressured = conversation(4)
await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true)
const small = conversation(1)
await postStep(ctx, agent(small, MODEL))
await preStep(ctx, agent(small, MODEL))
expect(small.events.some(event => event.type === 'compact/start')).toBe(false)
expect(compact.calls).toHaveLength(1)
})
it('skips post-step pressure when the step signal is already aborted', async () => {
it('skips pre-step pressure when the step signal is already aborted', async () => {
const ctx = createContext()
const compact = new TestCompactService(ctx, {
thresholdRatio: 0.5,
@@ -1468,8 +1472,8 @@ describe('automatic listener and loader composition', () => {
const pressured = conversation(4)
const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded')
await expect(postStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted')))
.resolves.toBeUndefined()
await expect(preStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted')))
.resolves.toEqual({ kind: 'enter', messages: [] })
expect(compactIfNeeded).not.toHaveBeenCalled()
expect(pressured.events.some(event => event.type === 'compact/start')).toBe(false)
@@ -1486,7 +1490,7 @@ describe('automatic listener and loader composition', () => {
compact.error = 'temporary failure'
const session = conversation(4)
await expect(postStep(ctx, agent(session, MODEL))).resolves.toBeUndefined()
await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] })
expect(warnings).toContainEqual(expect.stringContaining('temporary failure'))
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
})
@@ -1506,8 +1510,8 @@ describe('automatic listener and loader composition', () => {
})
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
await postStep(ctx, agent(session, MODEL))
await preStep(ctx, agent(session, MODEL))
await preStep(ctx, agent(session, MODEL))
expect(warnings).toEqual([
expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`),
@@ -1524,8 +1528,8 @@ describe('automatic listener and loader composition', () => {
})
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
await postStep(ctx, agent(session, MODEL))
await preStep(ctx, agent(session, MODEL))
await preStep(ctx, agent(session, MODEL))
expect(warnings).toEqual([
expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'),
@@ -1748,10 +1752,9 @@ describe('automatic listener and loader composition', () => {
it('delegates canonical overflow when no durable routed target exists', async () => {
const ctx = createContext()
void new TestCompactService(ctx)
const session = new Session(SessionId('headerless-overflow'))
const session = Session.create(SessionId('headerless-overflow'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
@@ -1809,7 +1812,7 @@ describe('automatic listener and loader composition', () => {
retainTokens: 180,
})
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
await preStep(ctx, agent(session, MODEL))
const summaries = session.events.filter(event => event.type === 'compact/summary').length
expect(summaries).toBe(1)
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
@@ -1824,7 +1827,7 @@ describe('automatic listener and loader composition', () => {
retainTokens: 180,
})
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
await preStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
@@ -1854,7 +1857,7 @@ describe('automatic listener and loader composition', () => {
await fiber.dispose()
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
await preStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
@@ -175,7 +175,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
@@ -185,12 +185,11 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function overflowHistorySeed(): SessionEvent[] {
const session = new Session(SessionId('overflow-history-seed'))
const session = Session.create(SessionId('overflow-history-seed'))
for (let turn = 1; turn <= 2; turn += 1) {
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
@@ -218,7 +217,7 @@ function overflowHistorySeed(): SessionEvent[] {
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
it('uses the model actually routed by agent/request for post-step pressure', async () => {
const { ctx } = await harness(8)
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
ctx.on('agent/request', async (_payload, next) => ({
...await next(), provider: 'mock', model: 'mock',
}))
try {
@@ -307,7 +306,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
describe('context-overflow recovery across the real loop and compact-basic', () => {
it.each(['thrown', 'in-band'] as const)(
'force-compacts a %s overflow between failed and retry steps',
'force-compacts a %s overflow within the retried step',
async (delivery) => {
const ctx = new Context()
const adapter = new OverflowRecoveryAdapter(delivery)
@@ -316,7 +315,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
ctx.on('agent/request', async (_payload, next) => ({
...await next(), provider: 'mock', model: 'mock',
}))
await ctx.plugin(BasicCompactService, {
@@ -353,18 +352,12 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
expect(retry).not.toContain('OLD HISTORY SENTINEL')
const events = [...agent.session.events]
const failedStepEnd = events.find(event =>
const stepStart = events.find(event =>
event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1,
)!
const stepEnd = events.find(event =>
event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1,
)!
const failedEnd = events.find(event =>
event.type === 'turn/end' && event.data.turn === 3,
)!
const retryStart = events.find(event =>
event.type === 'turn/start' && event.data.turn === 4,
)!
const retryStep = events.find(event =>
event.type === 'step/start' && event.data.turn === 4 && event.data.step === 1,
)!
const compaction = events.filter(event =>
event.type === 'compact/start'
|| event.type === 'compact/summary'
@@ -375,11 +368,13 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
'compact/summary',
'compact/end',
])
expect(retryStart.seq).toBeGreaterThan(failedEnd.seq)
expect(compaction.every(event =>
event.seq > failedStepEnd.seq && event.seq < failedEnd.seq,
event.seq > stepStart.seq && event.seq < stepEnd.seq,
)).toBe(true)
expect(retryStep.seq).toBeGreaterThan(retryStart.seq)
expect(events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn))
.toEqual([3])
expect(events.filter(event => event.type === 'step/start' && event.data.turn === 3))
.toHaveLength(1)
expect(events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
@@ -419,9 +414,9 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
expect(adapter.conversationRequests).toHaveLength(3)
expect(adapter.summaryRequests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
.toEqual([expect.objectContaining({ turn: 4, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-3).map(event => event.data.turn))
.toEqual([3, 4, 5])
.toEqual([expect.objectContaining({ turn: 3, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn))
.toEqual([3])
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
@@ -77,7 +77,7 @@ describe('real Loader composition', () => {
expect(unloaded).toEqual([])
expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
expect((loaded.compact as BasicCompactService).config).toMatchObject({
expect((loaded.compact as unknown as BasicCompactService).config).toMatchObject({
thresholdRatio: 0.5,
retainRatio: 0.125,
auto: false,
@@ -112,7 +112,7 @@ async function loopHarness(): Promise<LoopHarness> {
const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL })
const log: string[] = []
ctx.on('session/event', (_session, event) => {
if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`)
if (event.type === 'turn/start') log.push('turn/start')
if (event.type === 'turn/end') log.push('turn/end')
if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`)
if (event.type === 'compact/summary') log.push('compact/summary')
@@ -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)}`)
}
@@ -166,10 +169,10 @@ function deferred(): { promise: Promise<undefined>; resolve: () => void } {
/** A closed-tail session with compactable exchanges and no live agent. */
function closedConversation(turns = 2, lastTurnNumber = turns): Session {
const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
const session = Session.create(SessionId(`closed-${turns}-${lastTurnNumber}`))
for (let index = 1; index <= turns; index += 1) {
const turn = index === turns ? lastTurnNumber : index
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `${PROMPT} ${turn}` }],
source: { kind: 'user' },
@@ -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
}
@@ -217,7 +225,7 @@ function detachedService(): { ctx: Context; compact: GatedCompactService; flushe
let flushes = 0
vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => {
flushes += 1
return Promise.resolve()
return Promise.resolve(false)
})
return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes }
}
@@ -256,7 +264,7 @@ describe('compactNow through the real loop', () => {
const summary = log.indexOf('compact/summary')
const end = log.indexOf('compact/end:null')
const flush = log.indexOf('flush')
const nextTurn = log.indexOf('turn/start:message')
const nextTurn = log.indexOf('turn/start')
expect(start).toBeLessThan(summary)
expect(summary).toBeLessThan(end)
expect(end).toBeLessThan(flush)
@@ -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()
@@ -368,7 +380,7 @@ describe('compactNow through the real loop', () => {
describe('compactNow transaction and failure classification', () => {
it('returns null without writing a bracket for history that cannot be compacted', async () => {
const { compact } = detachedService()
const session = new Session(SessionId('empty'))
const session = Session.create(SessionId('empty'))
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
@@ -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)
@@ -410,7 +422,7 @@ describe('compactNow transaction and failure classification', () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
const reloaded = new Session(SessionId('stale-orphan'), [...original.events])
const reloaded = Session.create(SessionId('stale-orphan'), [...original.events])
const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
const orphan = reloaded.events.find(event => event.type === 'compact/start')
const agent = fakeAgent(reloaded, () => () => undefined)
@@ -424,9 +436,9 @@ describe('compactNow transaction and failure classification', () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('turn/start', { turn: 3 })
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events])
const agent = fakeAgent(reloaded, () => () => undefined)
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
@@ -436,7 +448,7 @@ describe('compactNow transaction and failure classification', () => {
it('refuses an open turn in the log', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 3 })
const agent = fakeAgent(session, () => () => undefined)
const error = await rejection(compact.compactNow(agent, SIGNAL))
@@ -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)
})
@@ -638,7 +650,7 @@ describe('compactNow transaction and failure classification', () => {
it('compacts a session with no durable turn boundary without creating one', async () => {
const { compact } = detachedService()
const session = new Session(SessionId('turnless'))
const session = Session.create(SessionId('turnless'))
for (const text of [PROMPT, 'recent tail']) {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
@@ -671,7 +683,7 @@ describe('compactNow transaction and failure classification', () => {
it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
const cases = [
{ name: 'busy', session: closedConversation(2), release: undefined },
{ name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined },
{ name: 'empty', session: Session.create(SessionId('pre-aborted-empty')), release: () => undefined },
{ name: 'compactable', session: closedConversation(2, 9), release: () => undefined },
] as const
@@ -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()
@@ -730,7 +763,7 @@ describe('compactNow transaction and failure classification', () => {
const { ctx, compact } = detachedService()
const controller = new AbortController()
const reason = new Error('cancelled during flush')
const flushGate = Promise.withResolvers<undefined>()
const flushGate = Promise.withResolvers<boolean>()
const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise)
const session = closedConversation(2)
let released = 0
@@ -750,7 +783,7 @@ describe('compactNow transaction and failure classification', () => {
expect(settled).toBe(false)
expect(released).toBe(0)
flushGate.resolve(undefined)
flushGate.resolve(false)
await expect(running).rejects.toBe(reason)
expect(released).toBe(1)
})
@@ -814,7 +847,7 @@ describe('compactNow transaction and failure classification', () => {
it('excludes a manual request while an explicit region compaction runs', async () => {
const { compact } = detachedService()
const session = closedConversation(3)
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 4 })
const agent = fakeAgent(session, () => () => undefined)
const gate = deferred()
compact.gate = gate.promise