import { describe, expect, test } from "bun:test"
import { AsyncResource } from "async_hooks"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { Identifier } from "../../src/id/id"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/kilocode/instance"
import { provideTestInstance } from "../fixture/fixture"
import { PlanFollowup } from "../../src/kilocode/plan-followup"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import { makeRuntime } from "../../src/effect/run-service"
import { Question } from "../../src/question"
import { Session } from "../../src/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import * as Log from "@opencode-ai/core/util/log"
import { tmpdir } from "../fixture/fixture"

Log.init({ print: false })

const sessions = {
  create: (input?: Parameters<Session.Interface["create"]>[0]) =>
    Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))),
  get: (id: SessionID) =>
    Effect.runPromise(Session.Service.use((svc) => svc.get(id)).pipe(Effect.provide(Session.defaultLayer))),
  messages: (input: Parameters<Session.Interface["messages"]>[0]) =>
    Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))),
  updateMessage: <T extends MessageV2.Info>(msg: T) =>
    Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))),
  updatePart: <T extends MessageV2.Part>(part: T) =>
    Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))),
}

const runtime = makeRuntime(Question.Service, Question.defaultLayer)
const questions = {
  ask(input: Parameters<Question.Interface["ask"]>[0]) {
    return runtime.runPromise((svc) => svc.ask(input))
  },
  list() {
    return runtime.runPromise((svc) => svc.list())
  },
  reject(requestID: Parameters<Question.Interface["reject"]>[0]) {
    return runtime.runPromise((svc) => svc.reject(requestID))
  },
  reply(input: Parameters<Question.Interface["reply"]>[0]) {
    return runtime.runPromise((svc) => svc.reply(input))
  },
}

const model = {
  providerID: ProviderID.make("openai"),
  modelID: ModelID.make("gpt-4"),
}

async function withInstance(fn: () => Promise<void>) {
  await using tmp = await tmpdir({ git: true })
  await provideTestInstance({ directory: tmp.path, fn })
}

async function seed(input: {
  text?: string
  agent?: string
  tools?: Array<{ tool: string; input: Record<string, unknown>; output: string }>
  finish?: string
}) {
  const session = await sessions.create({})
  const user = await sessions.updateMessage({
    id: MessageID.ascending(),
    role: "user",
    sessionID: session.id,
    time: { created: Date.now() },
    agent: input.agent ?? "plan",
    model,
  })
  await sessions.updatePart({
    id: PartID.ascending(),
    messageID: user.id,
    sessionID: session.id,
    type: "text",
    text: "Create a plan",
  })

  const assistant: MessageV2.Assistant = {
    id: MessageID.ascending(),
    role: "assistant",
    sessionID: session.id,
    time: { created: Date.now() },
    parentID: user.id,
    modelID: model.modelID,
    providerID: model.providerID,
    mode: input.agent ?? "plan",
    agent: input.agent ?? "plan",
    path: {
      cwd: Instance.directory,
      root: Instance.worktree,
    },
    cost: 0,
    tokens: {
      total: 0,
      input: 0,
      output: 0,
      reasoning: 0,
      cache: { read: 0, write: 0 },
    },
    finish: (input.finish as MessageV2.Assistant["finish"]) ?? "end_turn",
  }
  await sessions.updateMessage(assistant)
  if (input.text !== undefined) {
    await sessions.updatePart({
      id: PartID.ascending(),
      messageID: assistant.id,
      sessionID: session.id,
      type: "text",
      text: input.text,
    })
  }

  for (const t of input.tools ?? []) {
    await sessions.updatePart({
      id: PartID.ascending(),
      messageID: assistant.id,
      sessionID: session.id,
      type: "tool",
      callID: Identifier.ascending("tool"),
      tool: t.tool,
      state: {
        status: "completed",
        input: t.input,
        output: t.output,
        title: t.tool,
        metadata: {},
        time: { start: Date.now(), end: Date.now() },
      },
    } satisfies MessageV2.ToolPart)
  }

  const messages = await sessions.messages({ sessionID: session.id })
  return { sessionID: session.id, messages }
}

async function waitQuestion(sessionID: string) {
  for (let i = 0; i < 50; i++) {
    const list = await questions.list()
    const question = list.find((item) => item.sessionID === sessionID)
    if (question) return question
    await Bun.sleep(10)
  }
}

function content(message: MessageV2.WithParts) {
  return message.parts
    .filter((part): part is MessageV2.TextPart => part.type === "text")
    .map((part) => part.text)
    .join("\n")
}

describe("plan_exit detection", () => {
  test("PlanFollowup.ask triggers when plan_exit tool is present", () =>
    withInstance(async () => {
      const seeded = await seed({
        text: "Here is the plan",
        tools: [
          {
            tool: "plan_exit",
            input: {},
            output: "Plan is ready at .kilo/plans/plan.md. Ending planning turn.",
          },
        ],
      })
      expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(true)

      const pending = PlanFollowup.ask({
        question: questions,
        sessionID: seeded.sessionID,
        messages: seeded.messages,
        abort: AbortSignal.any([]),
      })

      const question = await waitQuestion(seeded.sessionID)
      expect(question).toBeDefined()
      if (!question) return
      expect(question.questions[0].header).toBe("Implement")
      await questions.reject(question.id)
      await expect(pending).resolves.toBe("break")
    }))

  test("KiloSessionPrompt resolves plan follow-up through the supplied question service", () =>
    withInstance(async () => {
      const seeded = await seed({
        text: "Here is the plan",
        tools: [
          {
            tool: "plan_exit",
            input: {},
            output: "Plan is ready. Ending planning turn.",
          },
        ],
      })

      const result = await Effect.runPromise(
        Effect.gen(function* () {
          const question = yield* Question.Service
          const pending = KiloSessionPrompt.askPlanFollowup({
            sessionID: seeded.sessionID,
            messages: seeded.messages,
            abort: AbortSignal.any([]),
            question,
          })
          const item = yield* Effect.gen(function* () {
            for (let i = 0; i < 50; i++) {
              const request = (yield* question.list()).find((entry) => entry.sessionID === seeded.sessionID)
              if (request) return request
              yield* Effect.sleep("10 millis")
            }
            throw new Error("timed out waiting for listener-local plan follow-up question")
          })
          yield* question.reply({ requestID: item.id, answers: [[PlanFollowup.ANSWER_CONTINUE]] })
          return yield* Effect.promise(() => pending)
        }).pipe(Effect.provide(Question.defaultLayer)),
      )

      expect(result).toBe("continue")
    }))

  test("KiloSessionPrompt cleans listener-local plan follow-up when aborted outside instance context", () => {
    const outside = new AsyncResource("plan-followup-abort-test")
    return withInstance(async () => {
      const seeded = await seed({
        text: "Here is the plan",
        tools: [
          {
            tool: "plan_exit",
            input: {},
            output: "Plan is ready. Ending planning turn.",
          },
        ],
      })

      const result = await Effect.runPromise(
        Effect.gen(function* () {
          const question = yield* Question.Service
          const abort = new AbortController()
          const pending = KiloSessionPrompt.askPlanFollowup({
            sessionID: seeded.sessionID,
            messages: seeded.messages,
            abort: abort.signal,
            question,
          })
          yield* Effect.gen(function* () {
            for (let i = 0; i < 50; i++) {
              const request = (yield* question.list()).find((entry) => entry.sessionID === seeded.sessionID)
              if (request) return request
              yield* Effect.sleep("10 millis")
            }
            throw new Error("timed out waiting for listener-local plan follow-up question")
          })
          outside.runInAsyncScope(() => abort.abort())
          const action = yield* Effect.promise(() =>
            Promise.race([pending, Bun.sleep(1_000).then(() => "timeout" as const)]),
          )
          expect(yield* question.list()).toEqual([])
          return action
        }).pipe(Effect.provide(Question.defaultLayer)),
      )

      expect(result).toBe("break")
    }).finally(() => outside.emitDestroy())
  })

  test("PlanFollowup skips prompt when aborted while resolving the plan", () =>
    withInstance(async () => {
      const seeded = await seed({
        text: "Here is the plan",
        tools: [
          {
            tool: "plan_exit",
            input: {},
            output: "Plan is ready. Ending planning turn.",
          },
        ],
      })
      const abort = new AbortController()
      const pending = PlanFollowup.ask({
        question: questions,
        sessionID: seeded.sessionID,
        messages: seeded.messages,
        abort: abort.signal,
      })
      abort.abort()

      const result = await Promise.race([pending, Bun.sleep(1_000).then(() => "timeout" as const)])
      const list = () => questions.list().then((qs) => qs.filter((q) => q.sessionID === seeded.sessionID))
      try {
        expect(result).toBe("break")
        expect(await list()).toEqual([])
      } finally {
        for (const item of await list()) {
          await questions.reject(item.id)
        }
      }
    }))

  test("JetBrains client enables plan follow-up with custom answer", () =>
    withInstance(async () => {
      const prev = process.env.KILO_CLIENT
      try {
        process.env.KILO_CLIENT = "jetbrains"
        const seeded = await seed({
          text: "Here is the plan",
          tools: [
            {
              tool: "plan_exit",
              input: {},
              output: "Plan is ready. Ending planning turn.",
            },
          ],
        })

        expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(
          true,
        )

        const pending = PlanFollowup.ask({
          question: questions,
          sessionID: seeded.sessionID,
          messages: seeded.messages,
          abort: AbortSignal.any([]),
        })

        const question = await waitQuestion(seeded.sessionID)
        expect(question).toBeDefined()
        if (!question) return
        expect(question.questions[0].question).toBe("Ready to implement?")
        expect(question.questions[0].header).toBe("Implement")
        expect(question.questions[0].custom).toBe(true)
        expect(question.questions[0].options.map((item) => item.label)).toEqual([
          PlanFollowup.ANSWER_NEW_SESSION,
          PlanFollowup.ANSWER_CONTINUE,
        ])
        expect(question.questions[0].options.find((item) => item.label === PlanFollowup.ANSWER_CONTINUE)?.mode).toBe(
          "code",
        )
        await questions.reject(question.id)
        await expect(pending).resolves.toBe("break")
      } finally {
        if (prev === undefined) delete process.env.KILO_CLIENT
        else process.env.KILO_CLIENT = prev
      }
    }))

  test("PlanFollowup.ask triggers and continue works with plan_exit", () =>
    withInstance(async () => {
      const seeded = await seed({
        text: "Here is the plan",
        tools: [
          {
            tool: "plan_exit",
            input: {},
            output: "Plan is ready. Ending planning turn.",
          },
        ],
      })

      const pending = PlanFollowup.ask({
        question: questions,
        sessionID: seeded.sessionID,
        messages: seeded.messages,
        abort: AbortSignal.any([]),
      })

      const question = await waitQuestion(seeded.sessionID)
      expect(question).toBeDefined()
      if (!question) return
      await questions.reply({
        requestID: question.id,
        answers: [[PlanFollowup.ANSWER_CONTINUE]],
      })

      await expect(pending).resolves.toBe("continue")

      const messages = await sessions.messages({ sessionID: seeded.sessionID })
      const user = messages
        .slice()
        .reverse()
        .find((m) => m.info.role === "user")
      expect(user?.info.role).toBe("user")
      if (!user || user.info.role !== "user") return
      expect(user.info.agent).toBe("code")
    }))

  test("plan agent completion without plan_exit does NOT trigger PlanFollowup", () =>
    withInstance(async () => {
      const seeded = await seed({
        text: "Here is a partial plan, I have questions",
      })
      expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(false)
      const list = await questions.list()
      expect(list).toHaveLength(0)
    }))

  test("plan_exit with non-completed status does NOT trigger", () =>
    withInstance(async () => {
      const session = await sessions.create({})
      const user = await sessions.updateMessage({
        id: MessageID.ascending(),
        role: "user",
        sessionID: session.id,
        time: { created: Date.now() },
        agent: "plan",
        model,
      })
      await sessions.updatePart({
        id: PartID.ascending(),
        messageID: user.id,
        sessionID: session.id,
        type: "text",
        text: "Create a plan",
      })

      const assistant: MessageV2.Assistant = {
        id: MessageID.ascending(),
        role: "assistant",
        sessionID: session.id,
        time: { created: Date.now() },
        parentID: user.id,
        modelID: model.modelID,
        providerID: model.providerID,
        mode: "plan",
        agent: "plan",
        path: { cwd: Instance.directory, root: Instance.worktree },
        cost: 0,
        tokens: {
          total: 0,
          input: 0,
          output: 0,
          reasoning: 0,
          cache: { read: 0, write: 0 },
        },
        finish: "end_turn",
      }
      await sessions.updateMessage(assistant)
      await sessions.updatePart({
        id: PartID.ascending(),
        messageID: assistant.id,
        sessionID: session.id,
        type: "text",
        text: "Here is the plan",
      })
      await sessions.updatePart({
        id: PartID.ascending(),
        messageID: assistant.id,
        sessionID: session.id,
        type: "tool",
        callID: Identifier.ascending("tool"),
        tool: "plan_exit",
        state: {
          status: "error",
          error: "Something went wrong",
          time: { start: Date.now(), end: Date.now() },
          metadata: {},
          input: {},
        },
      } satisfies MessageV2.ToolPart)

      const messages = await sessions.messages({ sessionID: session.id })

      // Verify the tool part IS present but errored (not completed)
      const toolPart = messages.flatMap((msg) => msg.parts).find((p) => p.type === "tool" && p.tool === "plan_exit")
      expect(toolPart).toBeDefined()
      expect(toolPart!.type === "tool" && toolPart!.state.status).toBe("error")

      // Use the shared predicate — errored plan_exit should not trigger
      expect(SessionPrompt.shouldAskPlanFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)

      // Confirm no questions were posted
      const list = await questions.list()
      expect(list).toHaveLength(0)
    }))

  test("plan_exit on earlier assistant message triggers when later message has text only", () =>
    withInstance(async () => {
      const session = await sessions.create({})
      // Use explicit timestamps to ensure deterministic message ordering
      const now = Date.now()
      const user = await sessions.updateMessage({
        id: MessageID.ascending(),
        role: "user",
        sessionID: session.id,
        time: { created: now },
        agent: "plan",
        model,
      })
      await sessions.updatePart({
        id: PartID.ascending(),
        messageID: user.id,
        sessionID: session.id,
        type: "text",
        text: "Create a plan",
      })

      // First assistant message: has plan_exit tool, finish = tool-calls
      const assistant1: MessageV2.Assistant = {
        id: MessageID.ascending(),
        role: "assistant",
        sessionID: session.id,
        time: { created: now + 1 },
        parentID: user.id,
        modelID: model.modelID,
        providerID: model.providerID,
        mode: "plan",
        agent: "plan",
        path: { cwd: Instance.directory, root: Instance.worktree },
        cost: 0,
        tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
        finish: "tool-calls",
      }
      await sessions.updateMessage(assistant1)
      await sessions.updatePart({
        id: PartID.ascending(),
        messageID: assistant1.id,
        sessionID: session.id,
        type: "tool",
        callID: Identifier.ascending("tool"),
        tool: "plan_exit",
        state: {
          status: "completed",
          input: {},
          output: "Plan is ready. Ending planning turn.",
          title: "plan_exit",
          metadata: {},
          time: { start: now + 1, end: now + 1 },
        },
      } satisfies MessageV2.ToolPart)

      // Second assistant message: text only, finish = end_turn (this is what lastAssistantMsg would point to)
      const assistant2: MessageV2.Assistant = {
        id: MessageID.ascending(),
        role: "assistant",
        sessionID: session.id,
        time: { created: now + 2 },
        parentID: user.id,
        modelID: model.modelID,
        providerID: model.providerID,
        mode: "plan",
        agent: "plan",
        path: { cwd: Instance.directory, root: Instance.worktree },
        cost: 0,
        tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
        finish: "end_turn",
      }
      await sessions.updateMessage(assistant2)
      await sessions.updatePart({
        id: PartID.ascending(),
        messageID: assistant2.id,
        sessionID: session.id,
        type: "text",
        text: "The plan is complete. I've called plan_exit.",
      })

      const messages = await sessions.messages({ sessionID: session.id })
      expect(SessionPrompt.shouldAskPlanFollowup({ messages, abort: AbortSignal.any([]) })).toBe(true)
    }))

  test("PlanFollowup.ask falls back to plan file for tool-only plan_exit turns", () =>
    withInstance(async () => {
      const seeded = await seed({
        tools: [
          {
            tool: "plan_exit",
            input: {},
            output: "Plan is ready. Ending planning turn.",
          },
        ],
      })

      const session = await sessions.get(seeded.sessionID)
      const plan = Session.plan(session, Instance.current)
      await fs.mkdir(path.dirname(plan), { recursive: true })
      await Bun.write(plan, "Do implementation step 1")

      const pending = PlanFollowup.ask({
        question: questions,
        sessionID: seeded.sessionID,
        messages: seeded.messages,
        abort: AbortSignal.any([]),
      })

      const question = await waitQuestion(seeded.sessionID)
      expect(question).toBeDefined()
      if (!question) return
      await questions.reply({
        requestID: question.id,
        answers: [[PlanFollowup.ANSWER_CONTINUE]],
      })
      await expect(pending).resolves.toBe("continue")
    }))

  test("plan reminder reuses custom plan_exit path when refining", () =>
    withInstance(async () => {
      const seeded = await seed({
        tools: [
          {
            tool: "plan_exit",
            input: { path: ".plans/fix.md" },
            output: "Plan is ready at .plans/fix.md. Ending planning turn.",
          },
        ],
      })
      const file = path.join(Instance.worktree, ".plans", "fix.md")
      await fs.mkdir(path.dirname(file), { recursive: true })
      await Bun.write(file, "Do implementation step 1")

      const session = await sessions.get(seeded.sessionID)
      const id = MessageID.ascending()
      const user: MessageV2.WithParts = {
        info: {
          id,
          role: "user",
          sessionID: seeded.sessionID,
          time: { created: Date.now() },
          agent: "Architect",
          model,
        },
        parts: [
          {
            id: PartID.ascending(),
            messageID: id,
            sessionID: seeded.sessionID,
            type: "text",
            text: "Continue refining",
          },
        ],
      }
      await KiloSessionPrompt.insertPlanReminders({
        agent: { name: "Architect", options: {} },
        session,
        userMessage: user,
        messages: [...seeded.messages, user],
      })

      const part = user.parts.at(-1)
      const text = part?.type === "text" ? part.text : ""
      expect(text).toContain("The current saved plan file is")
      expect(text.replaceAll(path.sep, "/")).toContain(".plans/fix.md")
      expect(text).toContain("Project/user instructions about plan location")
      expect(text).not.toContain("No plan file exists yet")
    }))

  test("native plan reminder prefers project plan path instructions over fallback", () =>
    withInstance(async () => {
      const session = await sessions.create({})
      const id = MessageID.ascending()
      const user: MessageV2.WithParts = {
        info: {
          id,
          role: "user",
          sessionID: session.id,
          time: { created: Date.now() },
          agent: "plan",
          model,
        },
        parts: [
          {
            id: PartID.ascending(),
            messageID: id,
            sessionID: session.id,
            type: "text",
            text: "Create a plan. AGENTS says plans go in plans/.",
          },
        ],
      }

      await KiloSessionPrompt.insertPlanReminders({
        agent: { name: "plan", options: {} },
        session,
        userMessage: user,
        messages: [user],
      })

      const part = user.parts.at(-1)
      const text = part?.type === "text" ? part.text : ""
      expect(text).toContain(`${session.time.created}-<concise-kebab-case-suffix>.md`)
      expect(text).toContain(`${session.time.created}-database-cache-plan.md`)
      expect(text).toContain("plans/ or .plans/")
      expect(text).not.toContain(Session.plan(session, Instance.current))
    }))

  test("native plan reminder reuses custom plan_exit path when refining", () =>
    withInstance(async () => {
      const seeded = await seed({
        tools: [
          {
            tool: "plan_exit",
            input: { path: ".plans/fix.md" },
            output: "Plan is ready at .plans/fix.md. Ending planning turn.",
          },
        ],
      })
      const file = path.join(Instance.worktree, ".plans", "fix.md")
      await fs.mkdir(path.dirname(file), { recursive: true })
      await Bun.write(file, "Do implementation step 1")

      const session = await sessions.get(seeded.sessionID)
      const id = MessageID.ascending()
      const user: MessageV2.WithParts = {
        info: {
          id,
          role: "user",
          sessionID: seeded.sessionID,
          time: { created: Date.now() },
          agent: "plan",
          model,
        },
        parts: [
          {
            id: PartID.ascending(),
            messageID: id,
            sessionID: seeded.sessionID,
            type: "text",
            text: "Continue refining",
          },
        ],
      }
      await KiloSessionPrompt.insertPlanReminders({
        agent: { name: "plan", options: {} },
        session,
        userMessage: user,
        messages: [...seeded.messages, user],
      })

      const text = content(user)
      expect(text).toContain("The current saved plan file is")
      expect(text.replaceAll(path.sep, "/")).toContain(".plans/fix.md")
      expect(text).not.toContain(`${session.time.created}-<concise-kebab-case-suffix>.md`)
      expect(text).not.toContain("No plan file exists yet")
    }))

  test("architect reminder prefers project plan path instructions over fallback", () =>
    withInstance(async () => {
      const session = await sessions.create({})
      const id = MessageID.ascending()
      const user: MessageV2.WithParts = {
        info: {
          id,
          role: "user",
          sessionID: session.id,
          time: { created: Date.now() },
          agent: "Architect",
          model,
        },
        parts: [
          {
            id: PartID.ascending(),
            messageID: id,
            sessionID: session.id,
            type: "text",
            text: "Create a plan. AGENTS says plans go in plans/.",
          },
        ],
      }

      await KiloSessionPrompt.insertPlanReminders({
        agent: { name: "Architect", options: {} },
        session,
        userMessage: user,
        messages: [user],
      })

      const part = user.parts.at(-1)
      const text = part?.type === "text" ? part.text : ""
      expect(text).toContain(`${session.time.created}-<concise-kebab-case-suffix>.md`)
      expect(text).toContain("plans/ or .plans/")
      expect(text).not.toContain("Default to")
      expect(text).not.toContain("A fallback plan file exists")
    }))

  test("PlanFollowup.ask shows prompt when plan text is on earlier assistant and last assistant is empty", () =>
    withInstance(async () => {
      const session = await sessions.create({})
      // Use explicit timestamps to ensure deterministic message ordering
      const now = Date.now()
      const user = await sessions.updateMessage({
        id: MessageID.ascending(),
        role: "user",
        sessionID: session.id,
        time: { created: now },
        agent: "plan",
        model,
      })
      await sessions.updatePart({
        id: PartID.ascending(),
        messageID: user.id,
        sessionID: session.id,
        type: "text",
        text: "Create a plan",
      })

      // First assistant message: has plan text + plan_exit tool
      const assistant1: MessageV2.Assistant = {
        id: MessageID.ascending(),
        role: "assistant",
        sessionID: session.id,
        time: { created: now + 1 },
        parentID: user.id,
        modelID: model.modelID,
        providerID: model.providerID,
        mode: "plan",
        agent: "plan",
        path: { cwd: Instance.directory, root: Instance.worktree },
        cost: 0,
        tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
        finish: "tool-calls",
      }
      await sessions.updateMessage(assistant1)
      await sessions.updatePart({
        id: PartID.ascending(),
        messageID: assistant1.id,
        sessionID: session.id,
        type: "text",
        text: "Here is the detailed plan:\n\n## Step 1\nDo something\n\n## Step 2\nDo something else",
      })
      await sessions.updatePart({
        id: PartID.ascending(),
        messageID: assistant1.id,
        sessionID: session.id,
        type: "tool",
        callID: Identifier.ascending("tool"),
        tool: "plan_exit",
        state: {
          status: "completed",
          input: {},
          output: "Plan is ready. Ending planning turn.",
          title: "plan_exit",
          metadata: {},
          time: { start: now + 1, end: now + 1 },
        },
      } satisfies MessageV2.ToolPart)

      // Second assistant message: empty (LLM follow-up after tool result)
      const assistant2: MessageV2.Assistant = {
        id: MessageID.ascending(),
        role: "assistant",
        sessionID: session.id,
        time: { created: now + 2 },
        parentID: user.id,
        modelID: model.modelID,
        providerID: model.providerID,
        mode: "plan",
        agent: "plan",
        path: { cwd: Instance.directory, root: Instance.worktree },
        cost: 0,
        tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
        finish: "end_turn",
      }
      await sessions.updateMessage(assistant2)

      const messages = await sessions.messages({ sessionID: session.id })

      // shouldAskPlanFollowup should detect plan_exit on the earlier message
      expect(SessionPrompt.shouldAskPlanFollowup({ messages, abort: AbortSignal.any([]) })).toBe(true)

      // PlanFollowup.ask should find plan text from the earlier assistant and show prompt
      const pending = PlanFollowup.ask({
        question: questions,
        sessionID: session.id,
        messages,
        abort: AbortSignal.any([]),
      })

      const question = await waitQuestion(session.id)
      expect(question).toBeDefined()
      if (!question) return
      expect(question.questions[0].header).toBe("Implement")
      await questions.reply({
        requestID: question.id,
        answers: [[PlanFollowup.ANSWER_CONTINUE]],
      })
      await expect(pending).resolves.toBe("continue")
    }))
})
