Merge remote-tracking branch 'origin/master' into worktree/pr628-merge-20260727
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/cordis-catalog/events.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/loop.ts # packages/core/agent-loop/tests/request-recovery.spec.ts # packages/core/agent/src/types.ts # packages/core/scope/tests/invariant.spec.ts # packages/llm/llm-retry/README.i18n.yaml # packages/llm/llm-retry/README.md # packages/llm/llm-retry/README.zh.md # packages/llm/llm-retry/src/index.ts # packages/llm/llm-retry/src/invariant.ts # packages/llm/llm-retry/tests/invariant.spec.ts # packages/llm/llm-retry/tests/retry.spec.ts # packages/plan/plan-mode/src/index.ts # packages/plan/plan-mode/tests/integration.spec.ts # packages/plan/plan-mode/tests/plan-mode.spec.ts
This commit is contained in:
@@ -8,9 +8,9 @@
|
||||
*
|
||||
* The state in force is folded from the session log (`plan/mode`, last one
|
||||
* wins), so resume and fork restore it without a live mirror. User selections
|
||||
* are held as pending intent until a turn boundary because every session event
|
||||
* is turn-enclosed. The service flushes before the affected request assembly
|
||||
* on prompt submission, ordinary continuation, and request-recovery retry.
|
||||
* are held as pending intent until an in-turn request boundary because every
|
||||
* session event is turn-enclosed. The service flushes at `agent/step` before
|
||||
* the affected request assembly, including retry turns.
|
||||
*
|
||||
* The exit tool remains registered while plan mode is inactive so crossing a
|
||||
* boundary changes only the prompt section, not the request tool catalog.
|
||||
@@ -145,9 +145,9 @@ export class PlanModeService extends Service {
|
||||
private readonly section: string
|
||||
|
||||
/**
|
||||
* Latest selection per session awaiting a turn-boundary flush. `narrate` is
|
||||
* true for user selections and false for the exit tool, whose result already
|
||||
* narrates the transition.
|
||||
* Latest selection per session awaiting an in-turn request-boundary flush.
|
||||
* `narrate` is true for user selections and false for the exit tool, whose
|
||||
* result already narrates the transition.
|
||||
*/
|
||||
private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
|
||||
|
||||
@@ -156,46 +156,20 @@ export class PlanModeService extends Service {
|
||||
this.section = resolveConfig(config).section
|
||||
let disposed = false
|
||||
|
||||
// Boundary flushes use loop interception seams, not post-commit
|
||||
// `session/event` observation. Flush after next(): a selection arriving
|
||||
// while a downstream async listener awaits must still shape the request
|
||||
// this boundary precedes. Failures are contained so policy cannot block a
|
||||
// prompt or turn; a failed append remains pending for a later boundary.
|
||||
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => {
|
||||
const decision = await next()
|
||||
if (!disposed) {
|
||||
try {
|
||||
this.onBoundary(agent)
|
||||
} catch (error) {
|
||||
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
|
||||
}
|
||||
}
|
||||
return decision
|
||||
}
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
|
||||
flushAfter(agent, next), { prepend: true })
|
||||
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) =>
|
||||
flushAfter(agent, next), { prepend: true })
|
||||
ctx.on('agent/request-error', async (
|
||||
agent,
|
||||
_turn,
|
||||
_step,
|
||||
_error,
|
||||
_failure,
|
||||
_priorFailures,
|
||||
_retryPolicy,
|
||||
_signal,
|
||||
next,
|
||||
) => {
|
||||
const decision = await next()
|
||||
// A waterfall can retain this wrapper after Cordis unregisters it.
|
||||
if (disposed || decision.action !== 'retry') return decision
|
||||
// The boundary flush uses the loop's `agent/step` interception seam, not
|
||||
// post-commit `session/event` observation. `agent/step` runs inside the
|
||||
// open turn before every request derivation (including turn 1 step 1), so
|
||||
// it is the sole flush point: prompt admission happens pre-turn, where a
|
||||
// `plan/mode` append would land outside any open turn. Failures are
|
||||
// contained so policy cannot block a turn; a failed append remains
|
||||
// pending for a later boundary.
|
||||
ctx.on('agent/step', (agent) => {
|
||||
if (disposed) return
|
||||
try {
|
||||
this.onBoundary(agent)
|
||||
} catch (error) {
|
||||
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
|
||||
}
|
||||
return decision
|
||||
}, { prepend: true })
|
||||
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
|
||||
|
||||
@@ -227,7 +201,7 @@ export class PlanModeService extends Service {
|
||||
return { kind: 'success', text: 'Plan mode is already inactive.' }
|
||||
}
|
||||
this.set(agent, true)
|
||||
if (message !== '') agent.steer([{ type: 'text', text: message }])
|
||||
if (message !== '') agent.steer({ content: [{ type: 'text', text: message }], source: { kind: 'user' } })
|
||||
return {
|
||||
kind: 'success',
|
||||
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
|
||||
@@ -324,7 +298,7 @@ export class PlanModeService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Select whether plan mode should be active from the next turn boundary.
|
||||
* Select whether plan mode should be active from the next request boundary.
|
||||
* Repeated selection of the current or already-pending state is a no-op.
|
||||
*
|
||||
* @param agent The agent to switch.
|
||||
|
||||
@@ -13,7 +13,7 @@ const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model drives the REAL plan-mode plugin
|
||||
* through the agent loop — the pending-intent flush at the turn boundary, the
|
||||
* through the agent loop — the pending-intent flush at the request boundary, the
|
||||
* assembly the soft layer shapes (the exit tool + mode section), and the
|
||||
* `request/header` snapshots every transition leaves.
|
||||
* Only the model is mocked; the loop, the session log, and the plugin are
|
||||
@@ -72,10 +72,10 @@ describe('plan mode through the agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
|
||||
// Selected while idle: the pending intent flushes at the first
|
||||
// prompt-submit, BEFORE the first assembly.
|
||||
// in-turn agent/step seam, before the first assembly.
|
||||
ctx.planMode.set(agent, true)
|
||||
|
||||
agent.followup([{ type: 'text', text: 'explore the repo' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = agent.session.events
|
||||
@@ -103,14 +103,14 @@ describe('plan mode through the agent loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'hello' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(foldPlanMode(agent.session.events)).toBe(false)
|
||||
const first = findEvent(agent.session.events, 'request/header')
|
||||
expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
|
||||
|
||||
ctx.planMode.set(agent, true)
|
||||
agent.followup([{ type: 'text', text: 'now plan' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'now plan' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = agent.session.events
|
||||
@@ -128,7 +128,7 @@ describe('plan mode through the agent loop', () => {
|
||||
expect(second.data.header.system).toContain('plan mode')
|
||||
})
|
||||
|
||||
it('a mode flip during request recovery shapes the retry before its assembly', async () => {
|
||||
it('a mode flip at error settlement shapes the retry before its assembly', async () => {
|
||||
const failedRequest = [{
|
||||
type: 'finish',
|
||||
reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } },
|
||||
@@ -136,22 +136,16 @@ describe('plan mode through the agent loop', () => {
|
||||
const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
|
||||
const recoveryEntered = Promise.withResolvers<true>()
|
||||
const releaseRecovery = Promise.withResolvers<true>()
|
||||
ctx.on('agent/request-error', async (
|
||||
subject, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next,
|
||||
subject, _turn, _step, _error, _failure, _priorFailures, _retryPolicy, _signal, next,
|
||||
) => {
|
||||
if (subject !== agent) return next()
|
||||
recoveryEntered.resolve(true)
|
||||
await releaseRecovery.promise
|
||||
return { action: 'retry' }
|
||||
ctx.planMode.set(agent, true)
|
||||
return { kind: 'retry' }
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.followup([{ type: 'text', text: 'plan after the transient failure' }])
|
||||
await recoveryEntered.promise
|
||||
ctx.planMode.set(agent, true)
|
||||
releaseRecovery.resolve(true)
|
||||
agent.followup({ content: [{ type: 'text', text: 'plan after the transient failure' }], source: { kind: 'user' } })
|
||||
await idle
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
@@ -160,8 +154,10 @@ describe('plan mode through the agent loop', () => {
|
||||
expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
|
||||
const log = agent.session.events
|
||||
const planMode = findEvent(log, 'plan/mode')
|
||||
const firstEnd = log.find(event => event.type === 'step/end' && event.data.step === 1)
|
||||
const retryStart = log.find(event => event.type === 'step/start' && event.data.step === 2)
|
||||
const firstEnd = log.find(event => event.type === 'step/end'
|
||||
&& event.data.turn === 1 && event.data.step === 1)
|
||||
const retryStart = log.find(event => event.type === 'step/start'
|
||||
&& event.data.turn === 2 && event.data.step === 1)
|
||||
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
|
||||
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
|
||||
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { agentEvents, type Agent, type RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
@@ -19,9 +19,8 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
|
||||
* Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
|
||||
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
|
||||
* real scoped `agent.ctx` minted through `createScope`.
|
||||
* Turn boundaries are simulated by appending the real boundary events and
|
||||
* dispatching the interception seams the loop fires there. Recovery retries
|
||||
* exercise the separate `agent/request-error` wrapper.
|
||||
* Request boundaries are simulated by dispatching the real prompt-admission
|
||||
* and between-step seams used by the loop.
|
||||
*/
|
||||
|
||||
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
|
||||
@@ -53,40 +52,15 @@ async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a boundary event and dispatch the interception seam the loop fires
|
||||
* there — `agent/prompt-submit` inside the just-opened turn,
|
||||
* `agent/turn-continuation` after the step closed. Recovery retries use the
|
||||
* separately covered `agent/request-error` wrapper; post-commit
|
||||
* `session/event` observers remain observe-only.
|
||||
* Dispatch either prompt admission or the between-step checkpoint.
|
||||
*/
|
||||
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
|
||||
const events = agentEvents(ctx, agent)
|
||||
if (type === 'turn/start') {
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await events.waterfall('agent/prompt-submit', [{ type: 'text', text: 'boundary probe' }], { kind: 'user' }, new AbortController().signal, () => Promise.resolve({ kind: 'allow' }))
|
||||
return
|
||||
}
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
await events.waterfall('agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal, () => Promise.resolve({ action: 'stop' }))
|
||||
}
|
||||
|
||||
/** Dispatch the closed-step recovery seam with one terminal decision. */
|
||||
function recoveryBoundary(
|
||||
ctx: Context,
|
||||
agent: Agent & { session: Session },
|
||||
decision: RequestErrorDecision,
|
||||
): Promise<RequestErrorDecision> {
|
||||
return agentEvents(ctx, agent).waterfall(
|
||||
'agent/request-error',
|
||||
1,
|
||||
1,
|
||||
new Error('request failed'),
|
||||
{ message: 'request failed', code: 'SERVER' },
|
||||
[],
|
||||
undefined,
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve(decision),
|
||||
)
|
||||
await events.serial('agent/step', 1, 2, new AbortController().signal)
|
||||
}
|
||||
|
||||
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
|
||||
@@ -203,33 +177,17 @@ describe('ctx.planMode: get/set', () => {
|
||||
})
|
||||
|
||||
describe('the boundary flush', () => {
|
||||
it('flushes the pending intent as a plan/mode at turn/start', async () => {
|
||||
it('does not flush at prompt admission — the seam is pre-turn, so the first step boundary lands it', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
// Prompt admission runs before any turn opens; a plan/mode appended there
|
||||
// would sit outside the turn. The pending intent survives admission and
|
||||
// the in-turn agent/step boundary flushes it before the request derives.
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: true })
|
||||
})
|
||||
|
||||
it('flushes a set() that arrives while a downstream listener is still awaiting (post-next ordering)', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
// A downstream async listener (the shipped hooks listeners' shape): the
|
||||
// selection lands DURING its await — after this boundary began, before it
|
||||
// returns. The prepended flush runs after next(), so the plan/mode still
|
||||
// precedes the request this boundary gates.
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
ctx.planMode.set(agent, true)
|
||||
await next()
|
||||
return decision
|
||||
})
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
await agentEvents(ctx, agent).waterfall(
|
||||
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
|
||||
() => Promise.resolve({ action: 'stop' }),
|
||||
)
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: true })
|
||||
})
|
||||
@@ -241,23 +199,35 @@ describe('the boundary flush', () => {
|
||||
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
// A downstream listener captured before disposal keeps the waterfall
|
||||
// continuation alive across the unload; the resumed wrapper must not
|
||||
// append through the disposed service.
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
|
||||
// A listener captured in the same dispatch snapshot keeps the plan-mode
|
||||
// callback alive across the unload; the resumed wrapper must not append
|
||||
// through the disposed service. Registered prepended AFTER the plugin so
|
||||
// it runs before plan-mode's own prepended flush.
|
||||
ctx.on('agent/step', async () => {
|
||||
await fiber.dispose()
|
||||
await next()
|
||||
return decision
|
||||
})
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
await agentEvents(ctx, agent).waterfall(
|
||||
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
|
||||
() => Promise.resolve({ action: 'stop' }),
|
||||
)
|
||||
}, { prepend: true })
|
||||
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
})
|
||||
|
||||
it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
|
||||
it('skips the step-seam flush after the plugin fiber is disposed (a captured listener must not write into a dead service)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
// Serial dispatch captures its listener list up front; prepending after
|
||||
// the plugin puts this listener ahead of the plugin's own prepended one,
|
||||
// so the plugin's captured callback still runs after the disposal below.
|
||||
ctx.on('agent/step', async () => {
|
||||
await fiber.dispose()
|
||||
}, { prepend: true })
|
||||
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
})
|
||||
|
||||
it('flushes at the between-step seam too', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
@@ -265,30 +235,6 @@ describe('the boundary flush', () => {
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the pending intent parked when recovery does not retry', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
expect(await recoveryBoundary(ctx, agent, { action: 'fail' })).toEqual({ action: 'fail' })
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
})
|
||||
|
||||
it('contains an append failure at the retry boundary without changing its decision', async () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
const agent = await agentWithSession(ctx)
|
||||
ctx.planMode.set(agent, true)
|
||||
const original = agent.session.append.bind(agent.session)
|
||||
agent.session.append = (((type: string, ...rest: unknown[]) => {
|
||||
if (type === 'plan/mode') throw new Error('backend gone')
|
||||
return (original as (...args: unknown[]) => unknown)(type, ...rest)
|
||||
}) as unknown) as typeof agent.session.append
|
||||
|
||||
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
})
|
||||
|
||||
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
|
||||
const ctx = await setup()
|
||||
@@ -313,7 +259,7 @@ describe('the boundary flush', () => {
|
||||
const agent = await agentWithSession(ctx)
|
||||
header(agent.session)
|
||||
ctx.planMode.set(agent, true)
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
|
||||
@@ -367,7 +313,7 @@ describe('the boundary flush', () => {
|
||||
expect(ctx.planMode.get(agent).pending).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains an append failure on the prompt-submit seam the same way', async () => {
|
||||
it('prompt admission never appends, so a broken backend surfaces only at the step boundary', async () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
@@ -379,6 +325,8 @@ describe('the boundary flush', () => {
|
||||
return (original as (...args: unknown[]) => unknown)(type, ...rest)
|
||||
}) as unknown) as typeof agent.session.append
|
||||
await boundary(ctx, agent, 'turn/start')
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
})
|
||||
@@ -570,7 +518,10 @@ describe('/plan', () => {
|
||||
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
|
||||
})
|
||||
expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
|
||||
expect(messageSteer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
|
||||
expect(messageSteer).toHaveBeenCalledExactlyOnceWith({
|
||||
content: [{ type: 'text', text: 'draft the migration' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves active plan mode, cancels a pending entry, and treats inactive exit as idempotent', async () => {
|
||||
@@ -592,7 +543,7 @@ describe('/plan', () => {
|
||||
.toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' })
|
||||
expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false })
|
||||
expect(enteringSteer).not.toHaveBeenCalled()
|
||||
await boundary(ctx, entering, 'turn/start')
|
||||
await boundary(ctx, entering, 'step/end')
|
||||
expect(ctx.planMode.get(entering)).toEqual({ active: false })
|
||||
expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
|
||||
@@ -605,7 +556,7 @@ describe('/plan', () => {
|
||||
expect(await ctx.commands.execute(active, '/plan off', signal))
|
||||
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
|
||||
expect(activeSteer).not.toHaveBeenCalled()
|
||||
await boundary(ctx, active, 'turn/start')
|
||||
await boundary(ctx, active, 'step/end')
|
||||
expect(ctx.planMode.get(active)).toEqual({ active: false })
|
||||
})
|
||||
|
||||
@@ -938,32 +889,6 @@ describe('exit_plan_mode', () => {
|
||||
})
|
||||
|
||||
describe('HMR disposal', () => {
|
||||
it('does not flush a retry boundary that resumes after plugin disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||
const agent = await agentWithSession(ctx, 'disposed-in-flight-recovery')
|
||||
const recoveryEntered = Promise.withResolvers<true>()
|
||||
const releaseRecovery = Promise.withResolvers<true>()
|
||||
ctx.on('agent/request-error', async (
|
||||
_agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, _next,
|
||||
) => {
|
||||
recoveryEntered.resolve(true)
|
||||
await releaseRecovery.promise
|
||||
return { action: 'retry' }
|
||||
})
|
||||
ctx.planMode.set(agent, true)
|
||||
|
||||
const recovery = recoveryBoundary(ctx, agent, { action: 'fail' })
|
||||
await recoveryEntered.promise
|
||||
await fiber.dispose()
|
||||
releaseRecovery.resolve(true)
|
||||
|
||||
expect(await recovery).toEqual({ action: 'retry' })
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
})
|
||||
|
||||
it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -979,7 +904,7 @@ describe('HMR disposal', () => {
|
||||
expect(ctx.get('planMode')).toBeUndefined()
|
||||
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
|
||||
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user