import { describe, expect, it, vi } from 'vitest'
import { Context } from '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 { Session, SessionId } from '@deepseek-ai/dsh-session'
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'
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
import type { PlanModeConfig } from '../src/index.ts'

const TEST_PLAN_SECTION = 'Test plan mode instructions.'
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`.
 * 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 }> {
  // A live store session when a store is mounted (the command executor logs
  // lifecycle events through it); bare otherwise (fold/tool-only benches).
  const session = new Session(SessionId(id))
  const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
  let scoped!: Context
  await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
    inject: ['tools'],
  }))
  ;(agent as { ctx?: Context }).ctx = scoped
  // Seeded plan state lands before the creation announcement, matching resume.
  if (active !== undefined) session.append('plan/mode', { active })
  // The loop announces creation after publication.
  ctx.emit('agent/created', agent)
  return agent
}

/** Assemble exactly as the loop does: the agent is both subject and scope. */
function assembleFor(ctx: Context, agent: Agent) {
  return ctx.systemPrompt.assemble({ agent, scope: 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)
  return ctx
}

/**
 * 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') {
    await events.waterfall(
      'agent/prompt-submit',
      createUserMessage({
        content: [{ type: 'text', text: 'boundary probe' }],
        source: { kind: 'user' },
      }),
      new AbortController().signal,
      () => Promise.resolve({ kind: 'allow' }),
    )
    return
  }
  await events.serial('agent/step', 1, 2, new AbortController().signal)
}

/** Open a turn so a selection queues for the boundary flush (the mid-turn shape). */
function openTurn(session: Session, turn = 0): void {
  session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
}

/** Close the open turn (the between-turns shape: selections commit immediately). */
function closeTurn(session: Session, turn = 0): void {
  session.append('turn/end', { turn, reason: { kind: 'completed' } })
}

/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
function header(session: Session): void {
  session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial' })
}

function noticeTexts(session: Session): string[] {
  return session.events
    .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
    .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join(''))
}

function registerNamedTools(ctx: Context, names: string[]): void {
  for (const name of names) {
    ctx.tools.register(defineContentToolFixture({
      name,
      description: `test tool ${name}`,
      parameters: {},
      execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
    }))
  }
}

/** Assert the mapped Code Mode SDK includes the stable plan exit binding and test tools. */
function expectPlanCodeSdkBindings(sdk: string): void {
  expect(sdk).toContain('interface ToolArgsMap {')
  expect(sdk).toContain('read: Record<string, JsonValue>;')
  expect(sdk).toContain('write: Record<string, JsonValue>;')
  expect(sdk).toContain('interface ToolOutputMap {')
  expect(sdk).toContain('exit_plan_mode: {\n    approved: true;\n  };')
  expect(sdk).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;')
}

let callCounter = 0
function execute(ctx: Context, name: string, agent?: Agent) {
  return ctx.tools.execute({
    callId: CallId(`call-${++callCounter}`),
    name,
    arguments: {},
    signal: new AbortController().signal,
    ...agent ? { agent } : {},
  })
}

describe('resolveConfig', () => {
  it('requires string, non-empty plan instructions', () => {
    expect(() => resolveConfig({} as PlanModeConfig))
      .toThrow('needs a string `section`')
    expect(() => resolveConfig({ section: 5 } as unknown as PlanModeConfig))
      .toThrow('needs a string `section`')
    expect(() => resolveConfig({ section: '   ' }))
      .toThrow('needs a non-empty `section`')
  })

  it('returns a detached plan config', () => {
    const config = { section: TEST_PLAN_SECTION }
    const resolved = resolveConfig(config)
    expect(resolved).toEqual(config)
    expect(resolved).not.toBe(config)
  })

  it('rejects fields outside the plan policy config', () => {
    expect(() => resolveConfig({ section: TEST_PLAN_SECTION, tools: ['read'] } as unknown as PlanModeConfig))
      .toThrow('unknown key(s) tools — config is { section }')
  })
})

describe('foldPlanMode', () => {
  it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
    const session = new Session(SessionId('fold'))
    expect(foldPlanMode(session.events)).toBe(false)
    session.append('plan/mode', { active: true })
    session.append('plan/mode', { active: false })
    session.append('plan/mode', { active: true })
    expect(foldPlanMode(session.events)).toBe(true)
  })

  it('folds a prefix when `end` is given', () => {
    const session = new Session(SessionId('fold-prefix'))
    session.append('plan/mode', { active: true })
    session.append('plan/mode', { active: false })
    expect(foldPlanMode(session.events, 1)).toBe(true)
    expect(foldPlanMode(session.events, 0)).toBe(false)
  })
})

describe('ctx.planMode: get/set', () => {
  it('reads the folded state', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    expect(ctx.planMode.get(agent)).toEqual({ active: false })
    agent.session.append('plan/mode', { active: true })
    expect(ctx.planMode.get(agent)).toEqual({ active: true })
  })

  it('selects inactive as the plan exit target during an open turn', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    agent.session.append('plan/mode', { active: true })
    openTurn(agent.session)
    expect(ctx.planMode.set(agent, false)).toBe('queued')
    expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
  })

  it('drops a no-op set (target equals pending, else the current fold)', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    openTurn(agent.session)
    expect(ctx.planMode.set(agent, false)).toBe('noop')
    expect(ctx.planMode.get(agent)).toEqual({ active: false })
    expect(ctx.planMode.set(agent, true)).toBe('queued')
    expect(ctx.planMode.set(agent, true)).toBe('noop')
    expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  })

  it('a between-turns selection commits plan/mode immediately (no boundary would come)', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx, 'agent-idle')
    expect(ctx.planMode.set(agent, true)).toBe('committed')
    expect(foldPlanMode(agent.session.events)).toBe(true)
    expect(ctx.planMode.get(agent)).toEqual({ active: true })
    // Immediately reversible, still without a boundary.
    expect(ctx.planMode.set(agent, false)).toBe('committed')
    expect(foldPlanMode(agent.session.events)).toBe(false)
    // A later boundary finds nothing pending — no double append.
    await boundary(ctx, agent, 'step/end')
    expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2)
  })

  it('a between-turns reversal of a mid-turn pending intent cancels without logging', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    openTurn(agent.session)
    expect(ctx.planMode.set(agent, true)).toBe('queued')
    closeTurn(agent.session)
    // Back to the logged state: the pending intent clears, nothing lands.
    expect(ctx.planMode.set(agent, false)).toBe('cancelled')
    expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
    expect(ctx.planMode.get(agent)).toEqual({ active: false })
  })

  it('a between-turns commit narrates when the last header told the model otherwise', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx, 'agent-idle-narrate')
    header(agent.session)
    ctx.planMode.set(agent, true)
    expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
  })
})

describe('the boundary flush', () => {
  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)
    openTurn(agent.session)
    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(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 })
  })

  it('skips the flush after the plugin fiber is disposed (a captured wrapper 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)
    openTurn(agent.session)
    ctx.planMode.set(agent, true)
    // 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()
    }, { 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('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)
    openTurn(agent.session)
    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)
    await boundary(ctx, agent, 'step/end')
    expect(foldPlanMode(agent.session.events)).toBe(true)
  })


  it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    openTurn(agent.session)
    ctx.planMode.set(agent, true)
    ctx.planMode.set(agent, false)
    await boundary(ctx, agent, 'turn/start')
    expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
    expect(noticeTexts(agent.session)).toEqual([])
  })

  it('narrates nothing before the first request header (the section is the state statement)', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    ctx.planMode.set(agent, true)
    await boundary(ctx, agent, 'turn/start')
    expect(noticeTexts(agent.session)).toEqual([])
  })

  it('narrates once when the flushed mode differs from what the last header told the model', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    header(agent.session)
    ctx.planMode.set(agent, true)
    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.'])
  })

  it('narrates a switch back to the default mode with the default wording', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    agent.session.append('plan/mode', { active: true })
    header(agent.session)
    ctx.planMode.set(agent, false)
    await boundary(ctx, agent, 'step/end')
    expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
  })

  it('stays silent when the header already reflects the flushed mode', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    agent.session.append('plan/mode', { active: true })
    header(agent.session)
    agent.session.append('plan/mode', { active: false })
    ctx.planMode.set(agent, true)
    await boundary(ctx, agent, 'step/end')
    expect(foldPlanMode(agent.session.events)).toBe(true)
    expect(noticeTexts(agent.session)).toEqual([])
  })


  it('contains an append failure instead of blocking the prompt or the turn', async () => {
    const ctx = await setup()
    const warn = vi.fn()
    ctx.logger.warn = warn as never
    const agent = await agentWithSession(ctx)
    openTurn(agent.session)
    ctx.planMode.set(agent, true)
    const original = agent.session.append.bind(agent.session)
    // Only the flush's own plan/mode append fails; the boundary event itself
    // lands (the loop appended it before the seam fires).
    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
    await boundary(ctx, agent, 'step/end')
    expect(warn).toHaveBeenCalledOnce()
    // The failed flush re-parks the intent (cleared only after a landed
    // append), so the next healthy boundary converges the log with the
    // picker's optimistic state instead of dropping the switch forever.
    expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
    agent.session.append = original
    await boundary(ctx, agent, 'step/end')
    expect(foldPlanMode(agent.session.events)).toBe(true)
    expect(ctx.planMode.get(agent).pending).toBeUndefined()
  })

  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
    const agent = await agentWithSession(ctx)
    openTurn(agent.session)
    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
    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 })
  })
})

describe('the soft layer', () => {
  it('keeps the tool schemas identical across default and plan mode', async () => {
    const ctx = await setup()
    registerNamedTools(ctx, ['read', 'write'])
    const agent = await agentWithSession(ctx)
    const defaultAssembly = await assembleFor(ctx, agent)
    expect(defaultAssembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
    expect(defaultAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')

    agent.session.append('plan/mode', { active: true })
    const planAssembly = await assembleFor(ctx, agent)
    expect(planAssembly.tools).toEqual(defaultAssembly.tools)
    expect(planAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
  })

  it('leaves an agent-less assembly untouched', async () => {
    const ctx = await setup()
    registerNamedTools(ctx, ['read'])
    const assembly = await ctx.systemPrompt.assemble()
    expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
    expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
  })

  it('keeps the full toolset in plan mode and renders the configured mode section', async () => {
    const ctx = await setup()
    registerNamedTools(ctx, ['read', 'write', 'todo_write'])
    const agent = await agentWithSession(ctx, 'agent-1', { active: true })
    const assembly = await assembleFor(ctx, agent)
    expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write', 'write'])
    expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
  })

  it('leaves foreign assemble additions alone (no assemble-layer filtering)', async () => {
    // Plan guidance does not filter the registry or later assembly additions.
    const ctx = new Context()
    await ctx.plugin(SystemPrompt)
    await ctx.plugin(ToolRegistry)
    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)
    registerNamedTools(ctx, ['read'])
    const planning = await agentWithSession(ctx, 'planning', { active: true })
    expect((await assembleFor(ctx, planning)).tools.map(tool => tool.name))
      .toEqual(['exit_plan_mode', 'read', 'added-later'])
    const defaulted = await agentWithSession(ctx, 'defaulted')
    expect((await assembleFor(ctx, defaulted)).tools.map(tool => tool.name))
      .toEqual(['exit_plan_mode', 'read', 'added-later'])
  })

  it('keeps run_code the only wire tool in plan mode under the registry Code Mode; the SDK gains the exit binding', async () => {
    // Minimal scriptable runtime: the SDK section resolves ctx.codeRuntime at
    // assembly time (the code-mode.spec fake's shape).
    class FakeRuntime extends CodeRuntime {
      readonly language = 'typescript'
      readonly isolation = 'fake'
      run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
    }
    const ctx = new Context()
    await ctx.plugin(SystemPrompt)
    await ctx.plugin(ToolRegistry, { mode: 'code' })
    await ctx.plugin(FakeRuntime)
    await ctx.plugin(PlanModeService, PLAN_CONFIG)
    registerNamedTools(ctx, ['read', 'write'])
    const agent = await agentWithSession(ctx, 'agent-1', { active: true })
    const assembly = await assembleFor(ctx, agent)
    expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
    // The SDK documents the full binding set plus the exit; plan mode never
    // prunes capabilities and restrains through guidance alone.
    const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
    expectPlanCodeSdkBindings(sdk)
  })

  it('keeps native wire schemas and the SDK in step under mode both', async () => {
    class FakeRuntime extends CodeRuntime {
      readonly language = 'typescript'
      readonly isolation = 'fake'
      run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
    }
    const ctx = new Context()
    await ctx.plugin(SystemPrompt)
    await ctx.plugin(ToolRegistry, { mode: 'both' })
    await ctx.plugin(FakeRuntime)
    await ctx.plugin(PlanModeService, PLAN_CONFIG)
    registerNamedTools(ctx, ['read', 'write'])
    const agent = await agentWithSession(ctx, 'agent-1', { active: true })
    const assembly = await assembleFor(ctx, agent)
    // The stable registry contribution reaches both surfaces: the exit tool
    // is present on the wire AND in the SDK alongside the untouched toolset.
    expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['exit_plan_mode', 'read', 'run_code', 'write'])
    const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
    expectPlanCodeSdkBindings(sdk)
  })

  it('keeps the Code Mode SDK byte-identical across mode switches', async () => {
    class FakeRuntime extends CodeRuntime {
      readonly language = 'typescript'
      readonly isolation = 'fake'
      run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
    }
    const withPlanMode = new Context()
    await withPlanMode.plugin(SystemPrompt)
    await withPlanMode.plugin(ToolRegistry, { mode: 'code' })
    await withPlanMode.plugin(FakeRuntime)
    await withPlanMode.plugin(PlanModeService, 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 ?? ''
    expectPlanCodeSdkBindings(defaultSdk)
    agent.session.append('plan/mode', { active: true })
    const planSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
    expect(planSdk).toBe(defaultSdk)

    // Loading the plan-mode plugin deliberately adds one stable binding compared
    // 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(FakeRuntime)
    registerNamedTools(bare, ['read', 'write'])
    const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
    expect(bareSdk).not.toContain('exit_plan_mode:')
    expect(defaultSdk).not.toBe(bareSdk)
  })
})

describe('no execution gating beyond the exit tool', () => {
  it('passes agent-less and default-mode executions through', async () => {
    const ctx = await setup()
    registerNamedTools(ctx, ['write'])
    const agentless = await execute(ctx, 'write')
    expect(agentless.isError).toBe(false)
    const agent = await agentWithSession(ctx)
    const defaulted = await execute(ctx, 'write', agent)
    expect(defaulted.isError).toBe(false)
  })

  it('runs every call in plan mode untouched — guidance and enforcement are separate axes', async () => {
    const ctx = await setup()
    registerNamedTools(ctx, ['read', 'write', 'bash'])
    const agent = await agentWithSession(ctx, 'agent-1', { active: true })
    for (const name of ['read', 'write', 'bash']) {
      const result = await execute(ctx, name, agent)
      expect(result.isError).toBe(false)
    }
  })
})

describe('/plan', () => {
  it('registers only when a commands service is composed and optionally submits the next-step message', async () => {
    const bare = await setup()
    expect(bare.get('commands')).toBeUndefined()

    const ctx = await setup()
    await ctx.plugin(CommandService)
    // The `ctx.inject` child mounts asynchronously once `commands` resolves.
    await new Promise(resolve => setImmediate(resolve))
    const plainAgent = await agentWithSession(ctx, 'plain-plan-command')
    openTurn(plainAgent.session)
    const plainSteer = vi.fn()
    ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
    expect(ctx.commands.list(plainAgent)).toEqual([
      { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
    ])

    const signal = new AbortController().signal
    expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
    expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
    const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
    expect(plain?.result).toEqual({
      kind: 'success',
      text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
    })
    expect(ctx.planMode.get(plainAgent)).toEqual({ active: false, pending: true })
    expect(plainSteer).not.toHaveBeenCalled()

    const messageAgent = await agentWithSession(ctx, 'message-plan-command')
    openTurn(messageAgent.session)
    const messageSteer = vi.fn()
    ;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
    const plan = await ctx.commands.execute(messageAgent, '/plan   draft the migration  ', signal)
    expect(plan?.result).toEqual({
      kind: 'success',
      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({
      id: expect.any(String) as unknown,
      role: 'user',
      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 () => {
    const ctx = await setup()
    await ctx.plugin(CommandService)
    await new Promise(resolve => setImmediate(resolve))
    const signal = new AbortController().signal

    const inactive = await agentWithSession(ctx, 'inactive-plan-command')
    expect((await ctx.commands.execute(inactive, '/plan off', signal))?.result)
      .toEqual({ kind: 'success', text: 'Plan mode is already inactive.' })
    expect(ctx.planMode.get(inactive)).toEqual({ active: false })

    const entering = await agentWithSession(ctx, 'entering-plan-command')
    openTurn(entering.session)
    const enteringSteer = vi.fn()
    ;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
    await ctx.commands.execute(entering, '/plan', signal)
    expect((await ctx.commands.execute(entering, '/plan off', signal))?.result)
      .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, 'step/end')
    expect(ctx.planMode.get(entering)).toEqual({ active: false })
    expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)

    const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
    openTurn(active.session)
    const activeSteer = vi.fn()
    ;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
    expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
      .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
    expect(ctx.planMode.get(active)).toEqual({ active: true, pending: false })
    expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
      .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
    expect(activeSteer).not.toHaveBeenCalled()
    await boundary(ctx, active, 'step/end')
    expect(ctx.planMode.get(active)).toEqual({ active: false })
  })

  it('idle sessions get the immediate-commit copy on both /plan and /plan off', async () => {
    const ctx = await setup()
    await ctx.plugin(CommandService)
    await new Promise(resolve => setImmediate(resolve))
    const signal = new AbortController().signal
    const agent = await agentWithSession(ctx, 'idle-plan-command')
    expect((await ctx.commands.execute(agent, '/plan', signal))?.result)
      .toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' })
    expect(foldPlanMode(agent.session.events)).toBe(true)
    expect((await ctx.commands.execute(agent, '/plan off', signal))?.result)
      .toEqual({ kind: 'success', text: 'Plan mode off.' })
    expect(foldPlanMode(agent.session.events)).toBe(false)
  })

  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 new Promise(resolve => setImmediate(resolve))
    const agent = await agentWithSession(ctx)
    expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan'])

    await fiber.dispose()

    expect(ctx.commands.list(agent)).toEqual([])
  })
})

describe('exit_plan_mode', () => {
  async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
    const ctx = await setup()
    await ctx.plugin(UserInteractionService)
    const asked: AskUserQuestionRequest[] = []
    if (answer !== undefined) {
      ctx.userInteraction.registerProvider({
        ask: (request) => {
          asked.push(request)
          return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] })
        },
      })
    }
    const agent = await agentWithSession(ctx, 'agent-1', { active: true })
    return { ctx, agent, asked }
  }

  function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') {
    return ctx.tools.execute({
      callId: CallId(`call-exit-${++callCounter}`),
      name: EXIT_PLAN_MODE,
      arguments: { plan },
      signal: new AbortController().signal,
      ...agent ? { agent } : {},
    })
  }

  it('registers the tool with one required plan argument', async () => {
    const ctx = await setup()
    const schema = ctx.tools.schemas().find(entry => entry.name === EXIT_PLAN_MODE)
    const parameters = schema?.parameters as { required?: string[]; properties?: Record<string, unknown> }
    expect(schema?.description).toMatch(/^Use only in plan mode\./)
    expect(Object.keys(parameters.properties ?? {})).toEqual(['plan'])
    expect(parameters.required).toEqual(['plan'])
  })

  it('rejects an agent-less call', async () => {
    const ctx = await setup()
    const result = await callExit(ctx, undefined)
    expect(result.isError).toBe(true)
    expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }])
  })

  it('rejects a call outside plan mode while remaining advertised', async () => {
    const ctx = await setup()
    const agent = await agentWithSession(ctx)
    expect(ctx.tools.schemas().map(tool => tool.name)).toContain(EXIT_PLAN_MODE)
    const result = await callExit(ctx, agent)
    expect(result.isError).toBe(true)
    expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }])
  })

  it('rejects an empty or heading-less plan before asking the reviewer', async () => {
    const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
    for (const plan of ['', 'do things']) {
      const result = await callExit(ctx, agent, plan)
      expect(result.isError).toBe(true)
      expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }])
    }
    expect(asked).toHaveLength(0)
    expect(foldPlanMode(agent.session.events)).toBe(true)
  })

  it('degrades to the manual exit when no user-interaction 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(foldPlanMode(agent.session.events)).toBe(true)
  })

  it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => {
    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(foldPlanMode(agent.session.events)).toBe(true)
  })

  it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => {
    const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
    const result = await callExit(ctx, agent)
    expect(result.isError).toBe(false)
    if (result.isError) throw new Error('expected approved plan result')
    expect(result.value).toEqual({ approved: true })
    expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }])
    // Boundary-applied, not a direct append: the fold stays plan until the
    // step's end, so the plan policy covers any remaining call of the SAME batch.
    expect(foldPlanMode(agent.session.events)).toBe(true)
    expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
    await boundary(ctx, agent, 'step/end')
    expect(foldPlanMode(agent.session.events)).toBe(false)
    expect(asked).toHaveLength(1)
    expect(asked[0]?.agent).toBe(agent)
    expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things')
    expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
  })

  it('carries the exact plan through a Code Mode review and logs the nested dispatch', async () => {
    const plan = '# Code Mode plan\n\nUse the existing seam.'
    class ExitRuntime extends CodeRuntime {
      readonly language = 'typescript'
      readonly isolation = 'fake'
      async run(request: CodeRunRequest): Promise<CodeRunResult> {
        const exit = request.bindings[0]?.functions[EXIT_PLAN_MODE]
        if (exit === undefined) throw new Error('missing exit_plan_mode binding')
        return { logs: [], value: await exit({ plan }) }
      }
    }
    const ctx = new Context()
    await ctx.plugin(SystemPrompt)
    await ctx.plugin(ToolRegistry, { mode: 'code' })
    await ctx.plugin(ExitRuntime)
    await ctx.plugin(PlanModeService, PLAN_CONFIG)
    await ctx.plugin(UserInteractionService)
    const asked: AskUserQuestionRequest[] = []
    ctx.userInteraction.registerProvider({
      ask: (request) => {
        asked.push(request)
        return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
      },
    })
    const agent = await agentWithSession(ctx, 'code-mode-exit', { active: true })

    const result = await ctx.tools.execute({
      callId: CallId(`call-exit-${++callCounter}`),
      name: RUN_CODE_NAME,
      arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })`, description: 'Submit the plan for review' },
      signal: new AbortController().signal,
      agent,
    })

    expect(result.isError).toBe(false)
    expect(asked).toHaveLength(1)
    expect(asked[0]?.questions[0]).toMatchObject({
      header: 'Plan review',
      question: 'Approve this plan and leave plan mode?',
      detail: plan,
    })
    expect(agent.session.events.find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({
      name: EXIT_PLAN_MODE,
      arguments: { plan },
      isError: false,
    })
    expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
  })

  it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
    const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
    const approved = await callExit(ctx, agent)
    expect(approved.isError).toBe(false)
    // Calls of the SAME assistant response (no boundary between) were
    // requested under the plan-shaped header — the fold stays plan for that
    // whole batch; the boundary flush is what flips the next step.
    expect(foldPlanMode(agent.session.events)).toBe(true)
    const assembly = await ctx.systemPrompt.assemble({ agent })
    expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
    expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
    await boundary(ctx, agent, 'step/end')
    expect(foldPlanMode(agent.session.events)).toBe(false)
    const afterExit = await ctx.systemPrompt.assemble({ agent })
    expect(afterExit.tools).toEqual(assembly.tools)
    expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
  })

  it('the exit flush narrates nothing — the tool result is the narration', async () => {
    const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
    header(agent.session)
    await callExit(ctx, agent)
    await boundary(ctx, agent, 'step/end')
    expect(foldPlanMode(agent.session.events)).toBe(false)
    expect(noticeTexts(agent.session)).toEqual([])
  })

  it('keep planning returns the corrective error carrying the feedback verbatim', async () => {
    const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'], custom: 'consider the resume path' })
    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; their feedback: consider the resume path' }])
    expect(foldPlanMode(agent.session.events)).toBe(true)
  })

  it('keep planning without feedback returns the generic corrective error', async () => {
    const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'] })
    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.' }])
  })

  it('a custom-text-only answer is feedback, never consent', async () => {
    const { ctx, agent } = await setupWithReview({ selected: [], custom: 'add tests first' })
    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; their feedback: add tests first' }])
    expect(foldPlanMode(agent.session.events)).toBe(true)
  })

  it('requires exactly the single Approve selection', async () => {
    const { ctx, agent } = await setupWithReview({ selected: ['Approve', 'Keep planning'] })
    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.' }])
    expect(foldPlanMode(agent.session.events)).toBe(true)
  })

  it('treats custom text alongside Approve as feedback, not consent', async () => {
    const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'change the tests' })
    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; their feedback: change the tests' }])
    expect(foldPlanMode(agent.session.events)).toBe(true)
  })

  it('treats duplicate review answer items as non-consent', async () => {
    const { ctx, agent } = await setupWithReview()
    ctx.userInteraction.registerProvider({
      ask: () => Promise.resolve({ answers: [
        { id: 'plan-review', selected: ['Approve'] },
        { id: 'plan-review', selected: ['Keep planning'] },
      ] }),
    })
    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.' }])
    expect(foldPlanMode(agent.session.events)).toBe(true)
  })

  it('a missing answer item reads as keep-planning', async () => {
    const { ctx, agent } = await setupWithReview()
    ctx.userInteraction.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.' }])
  })

  it('forwards the execution abort signal to the review question', async () => {
    const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
    const controller = new AbortController()
    const result = await ctx.tools.execute({
      callId: CallId(`call-exit-${++callCounter}`),
      name: EXIT_PLAN_MODE,
      arguments: { plan: '# P' },
      agent,
      signal: controller.signal,
    })
    expect(result.isError).toBe(false)
    expect(asked[0]?.signal).toBe(controller.signal)
  })

  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(UserInteractionService)
    let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
    ctx.userInteraction.registerProvider({
      ask: () => new Promise((resolve) => { answer = resolve }),
    })
    const agent = await agentWithSession(ctx, 'agent-1', { active: true })
    const pending = callExit(ctx, agent)
    // Let execute reach the review await, then unload the plugin (HMR) and
    // only afterwards approve. The boundary listeners are gone, so a success
    // would claim an exit that can never flush — the call must fail instead.
    await new Promise(resolve => setImmediate(resolve))
    await fiber.dispose()
    answer({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
    const result = await pending
    expect(result.isError).toBe(true)
    expect(result.content).toEqual([{ type: 'text', text: 'Error: the plan-mode service was reloaded while the plan was under review; present the plan again' }])
    expect(foldPlanMode(agent.session.events)).toBe(true)
  })

  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') } })
    const result = await callExit(ctx, agent)
    expect(result.isError).toBe(true)
    expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
    expect(foldPlanMode(agent.session.events)).toBe(true)
  })

  it('presents the call as a generic card titled by the plan first heading', async () => {
    const ctx = await setup()
    const def = ctx.tools.get(EXIT_PLAN_MODE)!
    expect(def.presentCall?.({ plan: '## Fix the flake\n\nsteps' })).toEqual({
      card: 'generic',
      title: 'Fix the flake',
      kind: 'other',
      content: [{ type: 'text', text: '## Fix the flake\n\nsteps' }],
    })
    expect(def.presentCall?.({ plan: 'no heading here' })).toEqual({
      card: 'generic',
      title: 'Plan',
      kind: 'other',
      content: [{ type: 'text', text: 'no heading here' }],
    })
  })

  it('presents the result as a generic review card', async () => {
    const ctx = await setup()
    const def = ctx.tools.get(EXIT_PLAN_MODE)!
    const content = [{ type: 'text' as const, text: 'ok' }]
    expect(def.presentResult?.({ plan: '# P' }, { content, isError: false })).toEqual({
      card: 'generic',
      title: 'Plan review',
      content,
    })
  })
})

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)
    const agent = await agentWithSession(ctx, 'disposed-recovery')
    openTurn(agent.session)
    ctx.planMode.set(agent, true)
    expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
    expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
    expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('plan:policy')

    await fiber.dispose()
    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')
    await boundary(ctx, agent, 'step/end')
    expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  })
})
