import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'

const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })

async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
  const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
  dirs.push(root)
  return { ctx: await mountPersistentHarness(root, adapter), root }
}

async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise<Context> {
  const ctx = new Context()
  await ctx.plugin(LlmService)
  await ctx.plugin(SessionStore)
  await ctx.plugin(SystemPrompt)
  await ctx.plugin(ToolRegistry)
  await ctx.plugin(AgentRegistry)
  await ctx.plugin(AgentLoop, { agents: [] })
  await ctx.plugin(SessionPersistenceJsonl, { root })
  ctx.llm.registerAdapter(['mock'], adapter)
  return ctx
}

async function persistSession(sessionId: SessionId): Promise<string> {
  const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')]))
  // Persistence deliberately has no artifact for a truly empty session. A
  // balanced completed turn is the smallest resumable log and avoids running
  // the model merely to construct this lifecycle fixture.
  const seed: SessionEvent[] = [
    { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
    { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
  ]
  const session = ctx.sessions.create(sessionId, { seed })
  await ctx.sessions.flush(session)
  await ctx.fiber.dispose()
  return root
}

function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  return new Promise((resolve) => {
    const dispose = ctx.on('agent/status', (subject, status) => {
      if (subject === agent && status === 'idle') { dispose(); resolve() }
    })
  })
}

/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
async function promptly<T>(task: Promise<T>): Promise<T> {
  const timeout = Promise.withResolvers<never>()
  const timer = setTimeout(() => { timeout.reject(new Error('lifecycle task did not settle promptly')) }, 1000)
  try {
    return await Promise.race([task, timeout.promise])
  } finally {
    clearTimeout(timer)
  }
}

describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
  it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
    const adapter = new MockAdapter([textResponse('hi')])
    const { ctx } = await persistentHarness(adapter)
    const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
    expect(agent.session.id).toBe('custom-session')
    expect(agent.session.header.cwd).toBe('/w')
    await ctx.fiber.dispose()
  })

  it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
    const adapter = new MockAdapter([textResponse('hi')])
    const { ctx } = await persistentHarness(adapter)
    await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
    // A second create with the SAME agent id but a fresh session id must reject
    // up front — and must NOT leave an orphaned 'sess-b' session behind.
    await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/)
    expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
    await ctx.fiber.dispose()
  })

  it('createAgent works without meta (no cwd)', async () => {
    const adapter = new MockAdapter([textResponse('hi')])
    const { ctx } = await persistentHarness(adapter)
    const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
    expect(agent.session.id).toBe('nometa-session')
    expect(agent.session.header.cwd).toBeUndefined()
    await ctx.fiber.dispose()
  })

  it('resume of a session with no cwd carries an undefined cwd header', async () => {
    // Lifecycle 1: create a no-cwd session and run a turn.
    const adapter1 = new MockAdapter([textResponse('a')])
    const { ctx: ctx1, root } = await persistentHarness(adapter1)
    const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
    a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
    await waitForIdle(ctx1, a1)
    await ctx1.fiber.dispose()

    // Lifecycle 2: resume it; the header cwd stays undefined (no-cwd branch).
    const adapter2 = new MockAdapter([textResponse('b')])
    const ctx2 = new Context()
    await ctx2.plugin(LlmService)
    await ctx2.plugin(SessionStore)
    await ctx2.plugin(SystemPrompt)
    await ctx2.plugin(ToolRegistry)
    await ctx2.plugin(AgentRegistry)
    await ctx2.plugin(AgentLoop, { agents: [] })
    await ctx2.plugin(SessionPersistenceJsonl, { root })
    ctx2.llm.registerAdapter(['mock'], adapter2)
    const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
    expect(a2.session.header.cwd).toBeUndefined()
    await ctx2.fiber.dispose()
  })

  it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => {
    // Lifecycle 1: a fresh createAgent emits session-start with source 'startup'.
    const adapter1 = new MockAdapter([textResponse('a')])
    const { ctx: ctx1, root } = await persistentHarness(adapter1)
    const sources1: string[] = []
    ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
    const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
    expect(sources1).toEqual(['startup'])
    a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
    await waitForIdle(ctx1, a1)
    await ctx1.fiber.dispose()

    // Lifecycle 2: resuming the persisted session emits session-start 'resume'.
    const adapter2 = new MockAdapter([textResponse('b')])
    const ctx2 = new Context()
    await ctx2.plugin(LlmService)
    await ctx2.plugin(SessionStore)
    await ctx2.plugin(SystemPrompt)
    await ctx2.plugin(ToolRegistry)
    await ctx2.plugin(AgentRegistry)
    await ctx2.plugin(AgentLoop, { agents: [] })
    await ctx2.plugin(SessionPersistenceJsonl, { root })
    ctx2.llm.registerAdapter(['mock'], adapter2)
    const sources2: string[] = []
    ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
    await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') })
    expect(sources2).toEqual(['resume'])
    await ctx2.fiber.dispose()
  })

  it('resume awaits setup while unpublished, then publishes a fully composed world in order', async () => {
    const sessionId = SessionId('resume-setup-success')
    const root = await persistSession(sessionId)
    const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
    const gate = Promise.withResolvers<undefined>()
    const setupStarted = Promise.withResolvers<undefined>()
    const order: string[] = []

    ctx.on('session/created', (session) => {
      expect(ctx.sessions.get(session.id)).toBe(session)
      expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session)
      order.push('session/created')
    })
    ctx.on('agent/created', (agent) => {
      expect(() => { agent.cancel('too early') }).toThrow(/cannot cancel before creation setup completes/)
      order.push('agent/created')
    })
    ctx.on('agent/session-start', (agent) => {
      expect(() => { agent.cancel('now live') }).not.toThrow()
      order.push('agent/session-start')
    })

    const resuming = ctx.agents.resume({
      agentId: AgentId('resumed-atomic'),
      resumeSessionId: sessionId,
      agentOptions: { model: 'mock' },
      setup: async (agentCtx) => {
        expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic'))
        expect(agentCtx.agent?.session.events).toHaveLength(2)
        agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
        agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
        order.push('setup:start')
        setupStarted.resolve(undefined)
        await gate.promise
        order.push('setup:end')
      },
    })

    await setupStarted.promise
    expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined()
    expect(ctx.sessions.get(sessionId)).toBeUndefined()
    expect(order).toEqual(['setup:start'])

    gate.resolve(undefined)
    const handle = await resuming
    expect(order).toEqual([
      'setup:start',
      'setup:end',
      'session/created',
      'setup-listener:session/created',
      'agent/created',
      'setup-listener:agent/created',
      'agent/session-start',
    ])
    await handle.dispose()
    await ctx.fiber.dispose()
  })

  it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
    const sessionId = SessionId('resume-setup-reject')
    const root = await persistSession(sessionId)
    const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
    const published: string[] = []
    ctx.on('session/created', () => void published.push('session/created'))
    ctx.on('agent/created', () => void published.push('agent/created'))
    ctx.on('agent/session-start', () => void published.push('agent/session-start'))

    await expect(ctx.agents.resume({
      agentId: AgentId('resume-reject'),
      resumeSessionId: sessionId,
      agentOptions: { model: 'mock' },
      setup: async () => {
        await Promise.resolve()
        throw new Error('resume setup failed')
      },
    })).rejects.toThrow('resume setup failed')

    expect(published).toEqual([])
    expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined()
    expect(ctx.sessions.get(sessionId)).toBeUndefined()
    const retry = await ctx.agents.resume({
      agentId: AgentId('resume-reject'),
      resumeSessionId: sessionId,
      agentOptions: { model: 'mock' },
    })
    await retry.dispose()
    await ctx.fiber.dispose()
  })

  it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
    const sessionId = SessionId('resume-setup-owner-unload')
    const root = await persistSession(sessionId)
    const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
    const gate = Promise.withResolvers<undefined>()
    const setupStarted = Promise.withResolvers<undefined>()
    const published: string[] = []
    ctx.on('session/created', () => void published.push('session/created'))
    ctx.on('agent/created', () => void published.push('agent/created'))

    let resuming!: ReturnType<typeof ctx.agents.resume>
    const owner = await ctx.plugin(Object.assign((inner: Context) => {
      resuming = inner.agents.resume({
        agentId: AgentId('resume-owner-race'),
        resumeSessionId: sessionId,
        agentOptions: { model: 'mock' },
        setup: async () => {
          setupStarted.resolve(undefined)
          await gate.promise
        },
      })
    }, { inject: ['agents'] }))
    await setupStarted.promise

    await owner.dispose()
    await expect(resuming).rejects.toThrow(/owner disposed during setup/)
    expect(published).toEqual([])
    expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined()
    expect(ctx.sessions.get(sessionId)).toBeUndefined()

    gate.resolve(undefined)
    await Promise.resolve()
    expect(published).toEqual([])
    await ctx.fiber.dispose()
  })

  it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => {
    const sessionId = SessionId('resume-load-owner-unload')
    const agentId = AgentId('resume-load-race')
    const root = await persistSession(sessionId)
    const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
    const snapshot = await ctx.sessionPersistence.load(sessionId)
    const lateLoad = Promise.withResolvers<typeof snapshot>()
    const loadStarted = Promise.withResolvers<undefined>()
    let loads = 0
    ctx.sessionPersistence.load = (id) => {
      expect(id).toBe(sessionId)
      loads += 1
      if (loads === 1) {
        loadStarted.resolve(undefined)
        return lateLoad.promise
      }
      return Promise.resolve(structuredClone(snapshot))
    }

    const published: string[] = []
    ctx.on('session/created', () => void published.push('session/created'))
    ctx.on('agent/created', () => void published.push('agent/created'))
    ctx.on('agent/session-start', () => void published.push('agent/session-start'))

    let resuming!: ReturnType<typeof ctx.agents.resume>
    const owner = await ctx.plugin(Object.assign((inner: Context) => {
      resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
    }, { inject: ['agents'] }))
    await loadStarted.promise

    const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
    await promptly(owner.dispose())
    expect(published).toEqual([])
    expect(ctx.agents.get(agentId)).toBeUndefined()
    expect(ctx.sessions.get(sessionId)).toBeUndefined()

    // owner.dispose() itself awaited transaction settlement and reservation
    // release: reuse the same identities BEFORE awaiting the resume rejection.
    const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
    await rejection
    expect(loads).toBe(2)
    expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])

    // Settlement of the abandoned backend promise cannot resume the old
    // transaction or emit a second publication after the retry owns the ids.
    lateLoad.resolve(structuredClone(snapshot))
    await Promise.resolve()
    await Promise.resolve()
    expect(ctx.agents.get(agentId)).toBe(retry.agent)
    expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
    expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])

    await retry.dispose()
    await ctx.fiber.dispose()
  })

  it('snapshots resume identities and agent options before persistence load', async () => {
    const sessionId = SessionId('resume-snapshot-source')
    const root = await persistSession(sessionId)
    const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
    const loaded = await ctx.sessionPersistence.load(sessionId)
    const loadGate = Promise.withResolvers<typeof loaded>()
    ctx.sessionPersistence.load = () => loadGate.promise

    const occupied = await ctx.agents.create({
      agentId: AgentId('occupied-agent'),
      sessionId: SessionId('occupied-session'),
      agentOptions: { model: 'mock' },
    })
    const options = {
      agentId: AgentId('accepted-agent'),
      resumeSessionId: sessionId,
      agentOptions: { model: 'mock' },
    }
    const resuming = ctx.agents.resume(options)

    options.agentId = AgentId('occupied-agent')
    options.resumeSessionId = SessionId('occupied-session')
    options.agentOptions.model = 'mutated-model'
    loadGate.resolve(structuredClone(loaded))

    const resumed = await resuming
    expect(resumed.agent.id).toBe(AgentId('accepted-agent'))
    expect(resumed.agent.session.id).toBe(sessionId)
    expect(resumed.agent.options.model).toBe('mock')
    expect(ctx.agents.get(AgentId('occupied-agent'))).toBe(occupied.agent)
    expect(ctx.sessions.get(SessionId('occupied-session'))).toBe(occupied.agent.session)

    await resumed.dispose()
    await occupied.dispose()
    await ctx.fiber.dispose()
  })

  it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
    // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
    // in its header) by creating it with a complete-turn seed — the write path
    // materializes the fork (header + seed) on disk.
    const seed: SessionEvent[] = [
      { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
      { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
    ]
    const adapter1 = new MockAdapter([textResponse('a')])
    const { ctx: ctx1, root } = await persistentHarness(adapter1)
    const forked = ctx1.sessions.create(SessionId('forked-sess'), {
      seed,
      meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length },
    })
    await ctx1.parallel('session/flush', forked)
    await ctx1.fiber.dispose()

    // Lifecycle 2: resume it; the parentSession + seedLength header survives the
    // round-trip (exercises resume's parentSession- and seedLength-present
    // branches). seedLength must come from the PERSISTED header, not from the
    // resume seed length (which is the whole stored log, not the original
    // boundary).
    const adapter2 = new MockAdapter([textResponse('b')])
    const ctx2 = new Context()
    await ctx2.plugin(LlmService)
    await ctx2.plugin(SessionStore)
    await ctx2.plugin(SystemPrompt)
    await ctx2.plugin(ToolRegistry)
    await ctx2.plugin(AgentRegistry)
    await ctx2.plugin(AgentLoop, { agents: [] })
    await ctx2.plugin(SessionPersistenceJsonl, { root })
    ctx2.llm.registerAdapter(['mock'], adapter2)
    const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
    expect(a2.session.header.parentSession).toBe('parent-sess')
    expect(a2.session.header.cwd).toBe('/w')
    expect(a2.session.header.seedLength).toBe(seed.length)
    await ctx2.fiber.dispose()
  })

  it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
    // Lifecycle 1: run a turn, then inject context while idle. The idle inject
    // wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)
    // — without an explicit flush or clean dispose, the notice must still reach
    // disk, since a crash before the next turn would otherwise lose it.
    const adapter1 = new MockAdapter([textResponse('answer')])
    const { ctx: ctx1, root } = await persistentHarness(adapter1)
    const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
    a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
    await waitForIdle(ctx1, a1)
    a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
    // Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose).
    await new Promise(r => setTimeout(r, 30))

    // A SEPARATE backend reads the on-disk log — proving the inject persisted
    // itself, not a later dispose drain.
    const probe = new Context()
    await probe.plugin(SessionStore)
    await probe.plugin(SessionPersistenceJsonl, { root })
    const loaded = await probe.sessionPersistence.load(SessionId('inject-sess'))
    expect(JSON.stringify(loaded.events)).toContain('background task 42 finished')
    await probe.fiber.dispose()
    await ctx1.fiber.dispose()
  })

  it('an idle inject() survives persist + resume (turn-enclosed, not dropped as crash tail)', async () => {
    // Lifecycle 1: run a turn, then inject context while idle. The idle inject
    // wraps its context/message in a one-shot turn so it is turn-enclosed —
    // otherwise scanLog would treat the trailing context as a crash tail and
    // drop it on reload (the bug this guards).
    const adapter1 = new MockAdapter([textResponse('answer')])
    const { ctx: ctx1, root } = await persistentHarness(adapter1)
    const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
    a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
    await waitForIdle(ctx1, a1)
    a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
    await ctx1.parallel('session/flush', a1.session)
    await ctx1.fiber.dispose()

    // Lifecycle 2: resume; the injected context is still in the derived history.
    const adapter2 = new MockAdapter([textResponse('next')])
    const ctx2 = new Context()
    await ctx2.plugin(LlmService)
    await ctx2.plugin(SessionStore)
    await ctx2.plugin(SystemPrompt)
    await ctx2.plugin(ToolRegistry)
    await ctx2.plugin(AgentRegistry)
    await ctx2.plugin(AgentLoop, { agents: [] })
    await ctx2.plugin(SessionPersistenceJsonl, { root })
    ctx2.llm.registerAdapter(['mock'], adapter2)
    const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
    const flat = JSON.stringify(a2.session.deriveMessages())
    expect(flat).toContain('background task 42 finished')
    await ctx2.fiber.dispose()
  })

  it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {
    // Lifecycle 1: run one full turn, persisting it.
    const adapter1 = new MockAdapter([textResponse('first answer')])
    const { ctx: ctx1, root } = await persistentHarness(adapter1)
    const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
    a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
    await waitForIdle(ctx1, a1)
    const events1 = [...a1.session.events]
    const seqs1 = events1.map(e => e.seq)
    expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous
    await ctx1.fiber.dispose()

    // Lifecycle 2: a brand-new context over the SAME root; resume the session.
    const adapter2 = new MockAdapter([textResponse('second answer')])
    const ctx2 = new Context()
    await ctx2.plugin(LlmService)
    await ctx2.plugin(SessionStore)
    await ctx2.plugin(SystemPrompt)
    await ctx2.plugin(ToolRegistry)
    await ctx2.plugin(AgentRegistry)
    await ctx2.plugin(AgentLoop, { agents: [] })
    await ctx2.plugin(SessionPersistenceJsonl, { root })
    ctx2.llm.registerAdapter(['mock'], adapter2)

    const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
    // The resumed session carries the prior history…
    expect(a2.session.id).toBe('sess-resume')
    expect(a2.session.events.length).toBe(events1.length)
    const replay = new Session(SessionId('replay'), events1)
    expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())

    // …and a new turn continues numbering (turn 2) with contiguous seqs.
    a2.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
    await waitForIdle(ctx2, a2)
    const allSeqs = a2.session.events.map(e => e.seq)
    expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates
    const turnStarts = a2.session.events.filter(e => e.type === 'turn/start')
    expect(turnStarts.map(e => e.type === 'turn/start' && e.data.turn)).toEqual([1, 2])
    await ctx2.fiber.dispose()
  })

  it('resume rejects when session persistence is not configured', async () => {
    // A harness WITHOUT the persistence plugin.
    const adapter = new MockAdapter([textResponse('x')])
    const ctx = new Context()
    await ctx.plugin(LlmService)
    await ctx.plugin(SessionStore)
    await ctx.plugin(SystemPrompt)
    await ctx.plugin(ToolRegistry)
    await ctx.plugin(AgentRegistry)
    await ctx.plugin(AgentLoop, { agents: [] })
    ctx.llm.registerAdapter(['mock'], adapter)
    await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') }))
      .rejects.toThrow(/session persistence is not configured/)
    await ctx.fiber.dispose()
  })
})
