feat(compact): recover context overflow (PR3 phase 2)
This commit is contained in:
@@ -118,7 +118,7 @@ describe('agent/prompt-submit', () => {
|
||||
|
||||
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
|
||||
// Prompt rewrites and injected context land before `agent/pre-step`, so a
|
||||
// compaction listener measures the current surface before the single derive.
|
||||
// surface listener sees the current state before the single derive.
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
@@ -130,8 +130,7 @@ describe('agent/prompt-submit', () => {
|
||||
additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
}))
|
||||
|
||||
// The pre-step seam (where compaction lives) derives the surface it would act
|
||||
// on. Capture what it sees on the first step.
|
||||
// Capture the surface visible at the generic pre-step seam on the first step.
|
||||
let preStepDerived: string | undefined
|
||||
ctx.on('agent/pre-step', (subject, _turn, step) => {
|
||||
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
|
||||
@@ -375,7 +374,7 @@ describe('agent/session-prefix', () => {
|
||||
expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
|
||||
})
|
||||
|
||||
it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => {
|
||||
it('composes before the first pre-step and records the prefix on the request header', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
@@ -386,20 +385,15 @@ describe('agent/session-prefix', () => {
|
||||
order.push('compose')
|
||||
return [reminder, ...await next()]
|
||||
})
|
||||
const seen: (readonly Message[])[] = []
|
||||
ctx.on('agent/pre-step', (_agent, _turn, _step, _system, sessionPrefix) => {
|
||||
ctx.on('agent/pre-step', () => {
|
||||
order.push('pre-step')
|
||||
seen.push(sessionPrefix)
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Composition precedes the pre-step seam, and the seam receives THIS
|
||||
// instance's composed prefix — a token-pressure gate (compaction) counts
|
||||
// what the request will actually carry, never a stale logged prefix.
|
||||
expect(order).toEqual(['compose', 'pre-step'])
|
||||
expect(seen[0]).toEqual([reminder])
|
||||
expect(agent.session.requestHeader()?.messagePrefix).toEqual([reminder])
|
||||
})
|
||||
|
||||
it('the canonical prepend pattern composes contributions in registration order', async () => {
|
||||
|
||||
@@ -486,9 +486,8 @@ describe('agent loop', () => {
|
||||
|
||||
it('agent/pre-step fires once per step before the step is opened', async () => {
|
||||
// Two steps (a tool call, then a final text turn) → two model calls → two
|
||||
// pre-step fires, each carrying the assembled full system prompt, BEFORE
|
||||
// the step is opened and its request is derived (the request the adapter
|
||||
// sees reflects any surface state at fire time).
|
||||
// pre-step fires BEFORE the step is opened and its request is derived (the
|
||||
// request the adapter sees reflects any surface state at fire time).
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', {}, 'calling echo'),
|
||||
textResponse('done'),
|
||||
@@ -500,21 +499,19 @@ describe('agent loop', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const fires: { turn: number; step: number; fullSystemPrompt: string }[] = []
|
||||
ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => {
|
||||
if (subject === agent) fires.push({ turn, step, fullSystemPrompt })
|
||||
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
|
||||
ctx.on('agent/pre-step', (subject, turn, step, signal) => {
|
||||
if (subject === agent) fires.push({ turn, step, signal })
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// One fire per step, in order, each with the assembled system prompt
|
||||
// (here just the loop's own harness-identity section — no persona set).
|
||||
const HARNESS = 'You are an AI agent powered by the DeepSeek Harness SDK.'
|
||||
expect(fires).toEqual([
|
||||
{ turn: 1, step: 1, fullSystemPrompt: HARNESS },
|
||||
{ turn: 1, step: 2, fullSystemPrompt: HARNESS },
|
||||
expect(fires.map(({ turn, step }) => ({ turn, step }))).toEqual([
|
||||
{ turn: 1, step: 1 },
|
||||
{ turn: 1, step: 2 },
|
||||
])
|
||||
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
|
||||
})
|
||||
|
||||
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
|
||||
|
||||
@@ -219,6 +219,48 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the successful step as disposed when disposal lands during post-step', async () => {
|
||||
const adapter = new FailureScriptAdapter([
|
||||
toolCallResponse('dispose-call', 'work', {}),
|
||||
textResponse('must not continue'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'work',
|
||||
description: 'do work',
|
||||
parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'worked' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('dispose-post-step'), { model: 'mock' })
|
||||
let entered!: () => void
|
||||
const postStepEntered = new Promise<void>((resolve) => { entered = resolve })
|
||||
ctx.on('agent/post-step', async (_agent, turn, step, signal) => {
|
||||
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
|
||||
entered()
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await postStepEntered
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const boundaries = agent.session.events.filter(event =>
|
||||
event.type === 'step/start' || event.type === 'step/end',
|
||||
)
|
||||
expect(boundaries.map(event => event.type)).toEqual(['step/start', 'step/end'])
|
||||
expect(boundaries.map(event => event.data)).toEqual([
|
||||
{ turn: 1, step: 1 },
|
||||
{ turn: 1, step: 1 },
|
||||
])
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'disposed' } },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['thrown', contextError()],
|
||||
['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]],
|
||||
|
||||
Reference in New Issue
Block a user