|
|
|
|
@@ -2,16 +2,16 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
|
|
import { createUserMessage, 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 ToolRuntime, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
|
|
|
|
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import { createScope } from '@deepseek-ai/dsh-scope'
|
|
|
|
|
import UserInteractionService, {
|
|
|
|
|
UserInteractionError, type AskUserQuestionRequest,
|
|
|
|
|
} from '@deepseek-ai/dsh-user-interaction'
|
|
|
|
|
import CommandService from '@deepseek-ai/dsh-commands'
|
|
|
|
|
import UserQuestionService, {
|
|
|
|
|
UserQuestionError, type AskUserQuestionRequest,
|
|
|
|
|
} from '@deepseek-ai/dsh-user-questions'
|
|
|
|
|
import CommandRuntime from '@deepseek-ai/dsh-commands'
|
|
|
|
|
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
|
|
|
|
import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
|
|
|
|
|
import PlanModeController, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
|
|
|
|
|
import type { PlanModeConfig } from '../src/index.ts'
|
|
|
|
|
|
|
|
|
|
const TEST_PLAN_SECTION = 'Test plan mode instructions.'
|
|
|
|
|
@@ -19,7 +19,7 @@ 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
|
|
|
|
|
* `ToolRuntime` services, with fake Agents carrying real `Session`s and a
|
|
|
|
|
* real scoped `agent.ctx` minted through `createScope`.
|
|
|
|
|
* Request boundaries are simulated by dispatching the real pre-step waterfall
|
|
|
|
|
* and the following `step/start` session event used by the loop.
|
|
|
|
|
@@ -68,8 +68,8 @@ function assembleFor(ctx: Context, agent: Agent) {
|
|
|
|
|
async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(PlanModeService, config)
|
|
|
|
|
await ctx.plugin(ToolRuntime)
|
|
|
|
|
await ctx.plugin(PlanModeController, config)
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -281,8 +281,8 @@ describe('the boundary flush', () => {
|
|
|
|
|
it('removes the pre-step flush when the plugin fiber is disposed', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(ToolRuntime)
|
|
|
|
|
const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG)
|
|
|
|
|
const agent = await agentWithSession(ctx)
|
|
|
|
|
openTurn(agent.session)
|
|
|
|
|
ctx.planMode.set(agent, true)
|
|
|
|
|
@@ -433,13 +433,13 @@ describe('the soft layer', () => {
|
|
|
|
|
// Plan guidance does not filter the registry or later assembly additions.
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(ToolRuntime)
|
|
|
|
|
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
|
|
|
|
const final = await next()
|
|
|
|
|
final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
|
|
|
|
|
return final
|
|
|
|
|
})
|
|
|
|
|
await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(PlanModeController, PLAN_CONFIG)
|
|
|
|
|
registerNamedTools(ctx, ['read'])
|
|
|
|
|
const planning = await agentWithSession(ctx, 'planning', { active: true })
|
|
|
|
|
expect((await assembleFor(ctx, planning)).tools.map(tool => tool.name))
|
|
|
|
|
@@ -459,9 +459,9 @@ describe('the soft layer', () => {
|
|
|
|
|
}
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
|
|
|
|
await ctx.plugin(ToolRuntime, { mode: 'code' })
|
|
|
|
|
await ctx.plugin(FakeRuntime)
|
|
|
|
|
await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(PlanModeController, PLAN_CONFIG)
|
|
|
|
|
registerNamedTools(ctx, ['read', 'write'])
|
|
|
|
|
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
|
|
|
|
|
const assembly = await assembleFor(ctx, agent)
|
|
|
|
|
@@ -480,9 +480,9 @@ describe('the soft layer', () => {
|
|
|
|
|
}
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry, { mode: 'both' })
|
|
|
|
|
await ctx.plugin(ToolRuntime, { mode: 'both' })
|
|
|
|
|
await ctx.plugin(FakeRuntime)
|
|
|
|
|
await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(PlanModeController, PLAN_CONFIG)
|
|
|
|
|
registerNamedTools(ctx, ['read', 'write'])
|
|
|
|
|
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
|
|
|
|
|
const assembly = await assembleFor(ctx, agent)
|
|
|
|
|
@@ -501,9 +501,9 @@ describe('the soft layer', () => {
|
|
|
|
|
}
|
|
|
|
|
const withPlanMode = new Context()
|
|
|
|
|
await withPlanMode.plugin(SystemPrompt)
|
|
|
|
|
await withPlanMode.plugin(ToolRegistry, { mode: 'code' })
|
|
|
|
|
await withPlanMode.plugin(ToolRuntime, { mode: 'code' })
|
|
|
|
|
await withPlanMode.plugin(FakeRuntime)
|
|
|
|
|
await withPlanMode.plugin(PlanModeService, PLAN_CONFIG)
|
|
|
|
|
await withPlanMode.plugin(PlanModeController, PLAN_CONFIG)
|
|
|
|
|
registerNamedTools(withPlanMode, ['read', 'write'])
|
|
|
|
|
const agent = await agentWithSession(withPlanMode)
|
|
|
|
|
const defaultSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
|
|
|
|
|
@@ -516,7 +516,7 @@ describe('the soft layer', () => {
|
|
|
|
|
// with a deployment that does not compose plan mode at all.
|
|
|
|
|
const bare = new Context()
|
|
|
|
|
await bare.plugin(SystemPrompt)
|
|
|
|
|
await bare.plugin(ToolRegistry, { mode: 'code' })
|
|
|
|
|
await bare.plugin(ToolRuntime, { mode: 'code' })
|
|
|
|
|
await bare.plugin(FakeRuntime)
|
|
|
|
|
registerNamedTools(bare, ['read', 'write'])
|
|
|
|
|
const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
|
|
|
|
|
@@ -553,7 +553,7 @@ describe('/plan', () => {
|
|
|
|
|
expect(bare.get('commands')).toBeUndefined()
|
|
|
|
|
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
await ctx.plugin(CommandService)
|
|
|
|
|
await ctx.plugin(CommandRuntime)
|
|
|
|
|
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
|
|
|
|
|
await new Promise(resolve => setImmediate(resolve))
|
|
|
|
|
const plainAgent = await agentWithSession(ctx, 'plain-plan-command')
|
|
|
|
|
@@ -595,7 +595,7 @@ describe('/plan', () => {
|
|
|
|
|
|
|
|
|
|
it('leaves active plan mode, cancels a pending entry, and treats inactive exit as idempotent', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
await ctx.plugin(CommandService)
|
|
|
|
|
await ctx.plugin(CommandRuntime)
|
|
|
|
|
await new Promise(resolve => setImmediate(resolve))
|
|
|
|
|
const signal = new AbortController().signal
|
|
|
|
|
|
|
|
|
|
@@ -633,7 +633,7 @@ describe('/plan', () => {
|
|
|
|
|
|
|
|
|
|
it('idle sessions get the immediate-commit copy on both /plan and /plan off', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
await ctx.plugin(CommandService)
|
|
|
|
|
await ctx.plugin(CommandRuntime)
|
|
|
|
|
await new Promise(resolve => setImmediate(resolve))
|
|
|
|
|
const signal = new AbortController().signal
|
|
|
|
|
const agent = await agentWithSession(ctx, 'idle-plan-command')
|
|
|
|
|
@@ -648,9 +648,9 @@ describe('/plan', () => {
|
|
|
|
|
it('removes the contributed command when the plan-mode plugin is disposed', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(CommandService)
|
|
|
|
|
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(ToolRuntime)
|
|
|
|
|
await ctx.plugin(CommandRuntime)
|
|
|
|
|
const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG)
|
|
|
|
|
await new Promise(resolve => setImmediate(resolve))
|
|
|
|
|
const agent = await agentWithSession(ctx)
|
|
|
|
|
expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan'])
|
|
|
|
|
@@ -665,10 +665,10 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(UserInteractionService)
|
|
|
|
|
await ctx.plugin(UserQuestionService)
|
|
|
|
|
const asked: AskUserQuestionRequest[] = []
|
|
|
|
|
if (answer !== undefined) {
|
|
|
|
|
ctx.userInteraction.registerProvider({
|
|
|
|
|
ctx.userQuestions.registerProvider({
|
|
|
|
|
ask: (request) => {
|
|
|
|
|
asked.push(request)
|
|
|
|
|
return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] })
|
|
|
|
|
@@ -725,12 +725,12 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
expect(foldPlanMode(agent.session.events)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('degrades to the manual exit when no user-interaction seam is composed', async () => {
|
|
|
|
|
it('degrades to the manual exit when no user-questions seam is composed', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
|
|
|
|
|
const result = await callExit(ctx, agent)
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction channel is available to review the plan; ask the user to switch the session mode instead' }])
|
|
|
|
|
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions channel is available to review the plan; ask the user to switch the session mode instead' }])
|
|
|
|
|
expect(foldPlanMode(agent.session.events)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
@@ -738,16 +738,16 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
const { ctx, agent } = await setupWithReview()
|
|
|
|
|
const result = await callExit(ctx, agent)
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction provider is registered' }])
|
|
|
|
|
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions provider is registered' }])
|
|
|
|
|
expect(foldPlanMode(agent.session.events)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects review from a runtime-owned agent with consumer-neutral guidance', async () => {
|
|
|
|
|
const ctx = await setup()
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(UserInteractionService)
|
|
|
|
|
await ctx.plugin(UserQuestionService)
|
|
|
|
|
const ask = vi.fn(async () => ({ answers: [{ id: 'plan-review', selected: ['Approve'] }] }))
|
|
|
|
|
ctx.userInteraction.registerProvider({ ask })
|
|
|
|
|
ctx.userQuestions.registerProvider({ ask })
|
|
|
|
|
const root = await agentWithSession(ctx, 'review-root')
|
|
|
|
|
const child = await agentWithSession(ctx, 'review-child', { active: true, owner: root })
|
|
|
|
|
|
|
|
|
|
@@ -794,13 +794,13 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
}
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
|
|
|
|
await ctx.plugin(ToolRuntime, { mode: 'code' })
|
|
|
|
|
await ctx.plugin(ExitRuntime)
|
|
|
|
|
await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(PlanModeController, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(UserInteractionService)
|
|
|
|
|
await ctx.plugin(UserQuestionService)
|
|
|
|
|
const asked: AskUserQuestionRequest[] = []
|
|
|
|
|
ctx.userInteraction.registerProvider({
|
|
|
|
|
ctx.userQuestions.registerProvider({
|
|
|
|
|
ask: (request) => {
|
|
|
|
|
asked.push(request)
|
|
|
|
|
return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
|
|
|
|
|
@@ -899,7 +899,7 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
|
|
|
|
|
it('treats duplicate review answer items as non-consent', async () => {
|
|
|
|
|
const { ctx, agent } = await setupWithReview()
|
|
|
|
|
ctx.userInteraction.registerProvider({
|
|
|
|
|
ctx.userQuestions.registerProvider({
|
|
|
|
|
ask: () => Promise.resolve({ answers: [
|
|
|
|
|
{ id: 'plan-review', selected: ['Approve'] },
|
|
|
|
|
{ id: 'plan-review', selected: ['Keep planning'] },
|
|
|
|
|
@@ -913,7 +913,7 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
|
|
|
|
|
it('a missing answer item reads as keep-planning', async () => {
|
|
|
|
|
const { ctx, agent } = await setupWithReview()
|
|
|
|
|
ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
|
|
|
|
|
ctx.userQuestions.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
|
|
|
|
|
const result = await callExit(ctx, agent)
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
|
|
|
|
|
@@ -931,8 +931,8 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
|
|
|
|
|
it('reads a dismissed review as the user taking the turn back, not as a failure', async () => {
|
|
|
|
|
const { ctx, agent } = await setupWithReview()
|
|
|
|
|
ctx.userInteraction.registerProvider({
|
|
|
|
|
ask: () => Promise.reject(new UserInteractionError(
|
|
|
|
|
ctx.userQuestions.registerProvider({
|
|
|
|
|
ask: () => Promise.reject(new UserQuestionError(
|
|
|
|
|
'the user cancelled ask_user_question', 'ASK_CANCELLED')),
|
|
|
|
|
})
|
|
|
|
|
const result = await callExit(ctx, agent)
|
|
|
|
|
@@ -943,8 +943,8 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
|
|
|
|
|
it('leaves every other review failure its own message', async () => {
|
|
|
|
|
const { ctx, agent } = await setupWithReview()
|
|
|
|
|
ctx.userInteraction.registerProvider({
|
|
|
|
|
ask: () => Promise.reject(new UserInteractionError(
|
|
|
|
|
ctx.userQuestions.registerProvider({
|
|
|
|
|
ask: () => Promise.reject(new UserQuestionError(
|
|
|
|
|
'ask_user_question was aborted before the user answered', 'ASK_ABORTED')),
|
|
|
|
|
})
|
|
|
|
|
const result = await callExit(ctx, agent)
|
|
|
|
|
@@ -970,12 +970,12 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
it('fails the call when the plugin is disposed while the review awaits (no phantom exit)', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(ToolRuntime)
|
|
|
|
|
const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(UserInteractionService)
|
|
|
|
|
await ctx.plugin(UserQuestionService)
|
|
|
|
|
let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
|
|
|
|
|
ctx.userInteraction.registerProvider({
|
|
|
|
|
ctx.userQuestions.registerProvider({
|
|
|
|
|
ask: () => new Promise((resolve) => { answer = resolve }),
|
|
|
|
|
})
|
|
|
|
|
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
|
|
|
|
|
@@ -994,7 +994,7 @@ describe('exit_plan_mode', () => {
|
|
|
|
|
|
|
|
|
|
it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
|
|
|
|
|
const { ctx, agent } = await setupWithReview()
|
|
|
|
|
ctx.userInteraction.registerProvider({ ask: () => { throw new Error('review aborted') } })
|
|
|
|
|
ctx.userQuestions.registerProvider({ ask: () => { throw new Error('review aborted') } })
|
|
|
|
|
const result = await callExit(ctx, agent)
|
|
|
|
|
expect(result.isError).toBe(true)
|
|
|
|
|
expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
|
|
|
|
|
@@ -1034,12 +1034,12 @@ describe('HMR disposal', () => {
|
|
|
|
|
it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SystemPrompt)
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
|
|
|
|
await ctx.plugin(ToolRuntime)
|
|
|
|
|
const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG)
|
|
|
|
|
const agent = await agentWithSession(ctx, 'disposed-recovery')
|
|
|
|
|
openTurn(agent.session)
|
|
|
|
|
ctx.planMode.set(agent, true)
|
|
|
|
|
expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
|
|
|
|
|
expect(ctx.get('planMode')).toBeInstanceOf(PlanModeController)
|
|
|
|
|
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
|
|
|
|
|
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('plan:policy')
|
|
|
|
|
|
|
|
|
|
|