/**
 * Unit tests for the Session runtime (a fake Session / Loader is injected; no
 * real LLM requests are made): driving and state transitions, 409 mutual
 * exclusion, the four approval modes and taking effect immediately on change,
 * abort collapsing to deny, self-healing id swaps, vault invalidation re-resuming
 * stale runtimes, and LLM / tool errors in the message stream being persisted
 * (core doesn't throw, so try/catch can't catch them).
 */
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { DatabaseSync } from "node:sqlite";
import {
  abortEvent,
  approvalDecision,
  assistantText,
  compactionBegin,
  compactionEnd,
  requestBegin,
  requestEnd,
  sessionMeta,
  thinkingMessage,
  toolCall,
  toolCallOutput,
  userSteeringText,
  userText,
  withOrigin,
} from "@prismshadow/penguin-core";
import type { ApproveFn, OmniMessage, TextPayload } from "@prismshadow/penguin-core";
import { openDatabase } from "../src/db/database.js";
import { HttpError } from "../src/http/errors.js";
import { SessionsRepo } from "../src/db/repos/sessions.js";
import type { SessionRow } from "../src/db/repos/sessions.js";
import { ChannelHub } from "../src/runtime/channel.js";
import type { ChannelEvent } from "../src/runtime/channel.js";
import type { ErrorRecordArgs, ErrorSink } from "../src/runtime/error-recorder.js";
import { SessionManager } from "../src/runtime/session-manager.js";
import type { RuntimeSession, SessionLoader } from "../src/runtime/session-manager.js";
import { SessionSources } from "../src/runtime/session-sources.js";
import type { TitleRequest } from "../src/runtime/title-generator.js";
import type { UsageContext } from "../src/runtime/usage-recorder.js";
import { waitFor } from "./helpers.js";

const ROW: SessionRow = {
  sessionId: "session-1",
  projectId: "p1",
  agentId: "a1",
  modelId: "m1",
  provider: "custom",
  workspace: "/tmp/w",
  approvalMode: "always-ask",
  title: null,
  createdAt: "2026-07-06T00:00:00.000Z",
};

/** A simple, scriptable fake Session: run yields one tool_call and requests approval for it. */
function approvalFakeSession(sessionId: string, toolName = "write_file"): RuntimeSession {
  return {
    sessionId,
    toolPermission: (name) => (name === "read_tool" ? "r" : "rw"),
    generateTitle: async () => ({ title: null, usage: null }),
    compactability: () => "ok" as const,
    steer: () => false,
    skipReconnectWait: () => false,
    async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
      const tc = toolCall({ name: toolName, arguments: "{}", toolCallId: "tc-1" });
      yield tc;
      const decision = await opts.approve(tc);
      yield approvalDecision(decision, "tc-1");
      if (opts.signal.aborted) {
        yield abortEvent();
        return;
      }
      yield assistantText(`decision=${decision}`);
    },
    async *compact() {
      yield compactionBegin({ reason: "manual", mode: "summarize", context: 1, turns: 1 });
      yield compactionEnd({ reason: "manual", mode: "summarize", status: "completed" });
    },
  };
}

describe("session-manager", () => {
  let db: DatabaseSync;
  let sessions: SessionsRepo;
  let channels: ChannelHub;
  let sources: SessionSources;
  let recorded: OmniMessage[];
  let recordedCtx: UsageContext[];

  const makeManager = (loader: SessionLoader, errors?: ErrorSink): SessionManager =>
    new SessionManager({
      sessions,
      channels,
      sources,
      loader,
      recorder: {
        record: async (ctx, msg) => {
          recordedCtx.push(ctx);
          recorded.push(msg);
        },
      },
      ...(errors ? { errors } : {}),
      log: () => {},
    });

  const loaderOf = (session: RuntimeSession): SessionLoader => ({ load: async () => session });

  const capture = (sessionId: string): ChannelEvent[] => {
    const events: ChannelEvent[] = [];
    channels.get(sessionId).subscribe((e) => events.push(e));
    return events;
  };

  const serverEvents = (events: ChannelEvent[]): { type: string; [k: string]: unknown }[] =>
    events
      .filter((e) => e.event === "server_event")
      .map((e) => JSON.parse(e.data) as { type: string });

  beforeEach(() => {
    db = openDatabase(":memory:");
    sessions = new SessionsRepo(db);
    sessions.insert(ROW);
    channels = new ChannelHub();
    sources = new SessionSources();
    recorded = [];
    recordedCtx = [];
  });
  afterEach(() => {
    channels.dispose();
    db.close();
  });

  it("unknown Session → 404", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    const err = await manager.startTask("session-ghost", [userText("x")]).catch((e: unknown) => e);
    expect((err as { status: number }).status).toBe(404);
  });

  it("startTask: publishes the input first; when driving ends it returns to idle and pushes task_state", async () => {
    sessions.updateApprovalMode("session-1", "allow-all");
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    const events = capture("session-1");
    const { sessionId } = await manager.startTask("session-1", [userText("hello")]);
    expect(sessionId).toBe("session-1");
    await waitFor(() => manager.statusOf("session-1") === "idle" && recorded.length >= 3);

    // The first entry is the input message (visible to other subscribers), followed by task_state: running.
    const first = JSON.parse(events[0]!.data) as { payload: { text: string } };
    expect(first.payload.text).toBe("hello");
    const states = serverEvents(events).filter((e) => e.type === "task_state");
    expect(states.map((s) => s.state)).toEqual(["running", "idle"]);
    // Driving a run flips the row's has_trace cache (listing then never walks for it).
    expect(sessions.findById("session-1")!.hasTrace).toBe(true);
    // Outputs and events are forwarded one by one and handed to the recorder.
    expect(recordedCtx[0]).toEqual({
      projectId: "p1",
      agentId: "a1",
      sessionId: "session-1",
      modelId: "m1",
      provider: "custom",
    });
  });

  it("startTask forwards thinkingLevel into session.run options (per-turn, this Task only)", async () => {
    sessions.updateApprovalMode("session-1", "allow-all");
    const seen: (string | undefined)[] = [];
    const fake: RuntimeSession = {
      sessionId: "session-1",
      toolPermission: () => "rw",
      generateTitle: async () => ({ title: null, usage: null }),
      compactability: () => "ok" as const,
      steer: () => false,
      skipReconnectWait: () => false,
      async *run(_input: OmniMessage[], opts: { thinkingLevel?: string }) {
        seen.push(opts.thinkingLevel);
        yield assistantText("ok");
      },
      async *compact(): AsyncGenerator<OmniMessage> {},
    };
    const manager = makeManager(loaderOf(fake));
    await manager.startTask("session-1", [userText("a")], { thinkingLevel: "high" });
    await waitFor(() => manager.statusOf("session-1") === "idle" && seen.length === 1);
    // Omitted on the next Task: the session falls back to its default (nothing forwarded).
    await manager.startTask("session-1", [userText("b")]);
    await waitFor(() => manager.statusOf("session-1") === "idle" && seen.length === 2);
    expect(seen).toEqual(["high", undefined]);
  });

  it("LLM / tool failures in the message stream are persisted via drive (source=llm / environment, with the current Session context)", async () => {
    sessions.updateApprovalMode("session-1", "allow-all");
    const captured: ErrorRecordArgs[] = [];
    // core folds LLM / tool failures into the message stream (no throw): a tool
    // failure produces one tool_call_output(failed), an LLM failure produces one
    // request_end(failed) + an abort carrying the real reason.
    const failing: RuntimeSession = {
      sessionId: "session-1",
      toolPermission: () => "rw",
      generateTitle: async () => ({ title: null, usage: null }),
      compactability: () => "ok" as const,
      steer: () => false,
      skipReconnectWait: () => false,
      async *run(): AsyncGenerator<OmniMessage> {
        yield requestBegin();
        yield toolCall({ name: "write_file", arguments: "{}", toolCallId: "tc-1" });
        yield toolCallOutput({
          output: "ls: /nope\n[tool error] exit code 2",
          toolCallId: "tc-1",
          stopReason: "failed",
        });
        yield requestEnd("failed");
        yield abortEvent("llm request error: 500 upstream");
      },
      async *compact(): AsyncGenerator<OmniMessage> {},
    };
    const manager = makeManager(loaderOf(failing), { record: (args) => captured.push(args) });
    await manager.startTask("session-1", [userText("run")]);
    await waitFor(() => manager.statusOf("session-1") === "idle" && captured.length >= 2);

    expect(captured.map((a) => [a.source, a.code, a.kind])).toEqual([
      ["environment", "tool_failed:write_file", "expected"], // error fed back to the model; the Agent adjusts on its own
      ["llm", "llm_failed", "unexpected"], // the abort follows it: the retries did not recover it, so a human is needed
    ]);
    expect(captured[0]!.ctx).toEqual({ projectId: "p1", agentId: "a1", sessionId: "session-1" });
    expect(String(captured[0]!.err)).toContain("[tool error] exit code 2");
    expect(String(captured[1]!.err)).toBe("llm request error: 500 upstream"); // the abort's real reason
  });

  it("mutual exclusion: startTask again while running → 409 task_in_progress; compact → 409", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);

    const again = await manager.startTask("session-1", [userText("x")]).catch((e: unknown) => e);
    expect((again as { status: number; code: string }).status).toBe(409);
    expect((again as { code: string }).code).toBe("task_in_progress");
    const compact = await manager.startCompact("session-1").catch((e: unknown) => e);
    expect((compact as { status: number }).status).toBe(409);

    manager.decideApproval("session-1", "tc-1", "allow");
    await waitFor(() => manager.statusOf("session-1") === "idle");
  });

  it("steer: forwards to the running session; idle / lost race → 409 not_running", async () => {
    const steered: string[] = [];
    const fake = approvalFakeSession("session-1");
    fake.steer = (input: OmniMessage[]) => {
      steered.push((input[0]!.payload as TextPayload).text);
      return true;
    };
    const manager = makeManager(loaderOf(fake));
    const steerErr = (text: string): unknown => {
      try {
        manager.steer("session-1", [userText(text)], { text, images: 0, files: 0 });
        return null;
      } catch (e) {
        return e;
      }
    };

    // Not running (entry not even loaded): 409 not_running (typed like task_in_progress).
    const before = steerErr("early") as HttpError;
    expect(before.status).toBe(409);
    expect(before.code).toBe("not_running");

    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
    // Running: forwarded to the core session (no SSE event of its own).
    expect(steerErr("focus on tests")).toBeNull();
    expect(steered).toEqual(["focus on tests"]);

    // The core session lost the race (its run just finished): also 409, so the
    // caller falls back to a normal task.
    fake.steer = () => false;
    expect((steerErr("late") as HttpError).code).toBe("not_running");

    manager.decideApproval("session-1", "tc-1", "allow");
    await waitFor(() => manager.statusOf("session-1") === "idle");
    // Idle again after the run: 409.
    expect((steerErr("post") as HttpError).code).toBe("not_running");
  });

  it("pendingSteering mirror: broadcast with task_state on steer, shifted at delivery, dropped at run end", async () => {
    // Two approval parks, with core's delivery shape — one `[user_steering]` user text —
    // yielded between them, so the mid-run shift is observable while the run keeps going.
    const fake: RuntimeSession = {
      ...approvalFakeSession("session-1"),
      steer: () => true,
      async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
        const tc1 = toolCall({ name: "write_file", arguments: "{}", toolCallId: "tc-1" });
        yield tc1;
        yield approvalDecision(await opts.approve(tc1), "tc-1");
        yield userText(userSteeringText("focus on tests"));
        const tc2 = toolCall({ name: "write_file", arguments: "{}", toolCallId: "tc-2" });
        yield tc2;
        yield approvalDecision(await opts.approve(tc2), "tc-2");
        yield assistantText("done");
      },
    };
    const manager = makeManager(loaderOf(fake));
    const events = capture("session-1");
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);

    // Two queued steering messages: the mirror keeps both, in queue order.
    manager.steer("session-1", [userText("a")], { text: "focus on tests", images: 0, files: 0 });
    manager.steer("session-1", [userText("b")], { text: "later", images: 1, files: 2 });
    expect(manager.pendingSteeringOf("session-1")).toEqual([
      { text: "focus on tests", images: 0, files: 0 },
      { text: "later", images: 1, files: 2 },
    ]);

    // First delivery observed on the stream: the mirror shifts while the run is still going.
    manager.decideApproval("session-1", "tc-1", "allow");
    await waitFor(() => manager.pendingSteeringOf("session-1").length === 1);
    expect(manager.statusOf("session-1")).toBe("running");
    expect(manager.pendingSteeringOf("session-1")).toEqual([
      { text: "later", images: 1, files: 2 },
    ]);

    // Run end: core discards undelivered steering, and the mirror goes with it.
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
    manager.decideApproval("session-1", "tc-2", "allow");
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(manager.pendingSteeringOf("session-1")).toEqual([]);

    // task_state broadcasts traced the whole lifecycle: grow to 1, 2, shift to 1, gone at idle.
    const states = serverEvents(events).filter((e) => e.type === "task_state");
    const mirrored = states
      .filter((e) => e.pendingSteering !== undefined)
      .map((e) => (e.pendingSteering as unknown[]).length);
    expect(mirrored).toEqual([1, 2, 1]);
    const last = states[states.length - 1]!;
    expect(last.state).toBe("idle");
    expect(last.pendingSteering).toBeUndefined();
  });

  it("queueIfBusy: enqueues while running, auto-starts in order after each finish; abort keeps the queue", async () => {
    sessions.updateApprovalMode("session-1", "always-ask");
    const runInputs: string[][] = [];
    const fake = approvalFakeSession("session-1");
    const origRun = fake.run.bind(fake);
    fake.run = function (input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
      runInputs.push(input.map((m) => (m.payload as { text?: string }).text ?? ""));
      return origRun(input, opts);
    };
    const manager = makeManager(loaderOf(fake));
    const events = capture("session-1");

    const first = await manager.startTask("session-1", [userText("task 1")]);
    expect(first.queued).toBe(false);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);

    // Busy + queueIfBusy: held server-side instead of 409 (order preserved); without the
    // flag the 409 mutual exclusion is unchanged.
    const q1 = await manager.startTask("session-1", [userText("follow-up 1")], {
      queueIfBusy: true,
    });
    const q2 = await manager.startTask("session-1", [userText("follow-up 2")], {
      queueIfBusy: true,
    });
    expect([q1.queued, q2.queued]).toEqual([true, true]);
    expect(manager.pendingFollowUpCount("session-1")).toBe(2);
    const conflict = await manager.startTask("session-1", [userText("x")]).catch((e: unknown) => e);
    expect((conflict as HttpError).code).toBe("task_in_progress");

    // Abort the running task: queued follow-ups are future tasks — NOT discarded; the next
    // one auto-starts as an ordinary task once the aborted run settles.
    manager.abortTask("session-1");
    await waitFor(() => runInputs.length === 2);
    expect(runInputs[1]).toEqual(["follow-up 1"]);
    expect(manager.pendingFollowUpCount("session-1")).toBe(1);

    // Finishing each run starts the next queued input, in order, one at a time.
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
    manager.decideApproval("session-1", "tc-1", "allow");
    await waitFor(() => runInputs.length === 3);
    expect(runInputs[2]).toEqual(["follow-up 2"]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
    manager.decideApproval("session-1", "tc-1", "allow");
    await waitFor(
      () =>
        manager.statusOf("session-1") === "idle" && manager.pendingFollowUpCount("session-1") === 0,
    );

    // task_state events report the queued count (for the input area's "N queued" hint).
    const states = serverEvents(events).filter((e) => e.type === "task_state");
    expect(states.some((s) => s.queued === 2)).toBe(true);
    expect(states[states.length - 1]).toMatchObject({ state: "idle", queued: 0 });
  });

  it("queueIfBusy during compaction: the queue drains once the compaction ends", async () => {
    const runInputs: string[][] = [];
    let releaseCompact: (() => void) | null = null;
    const fake: RuntimeSession = {
      sessionId: "session-1",
      toolPermission: () => "rw",
      generateTitle: async () => ({ title: null, usage: null }),
      compactability: () => "ok" as const,
      steer: () => false,
      skipReconnectWait: () => false,
      // eslint-disable-next-line require-yield
      async *run(input: OmniMessage[]): AsyncGenerator<OmniMessage> {
        runInputs.push(input.map((m) => (m.payload as { text?: string }).text ?? ""));
      },
      async *compact(): AsyncGenerator<OmniMessage> {
        await new Promise<void>((resolve) => {
          releaseCompact = resolve;
        });
        yield compactionBegin({ reason: "manual", mode: "summarize", context: 1, turns: 1 });
        yield compactionEnd({ reason: "manual", mode: "summarize", status: "completed" });
      },
    };
    const manager = makeManager(loaderOf(fake));
    await manager.startCompact("session-1");
    await waitFor(() => releaseCompact !== null);

    const q = await manager.startTask("session-1", [userText("after compact")], {
      queueIfBusy: true,
    });
    expect(q.queued).toBe(true);
    expect(manager.pendingFollowUpCount("session-1")).toBe(1);

    releaseCompact!();
    await waitFor(() => runInputs.length === 1 && manager.pendingFollowUpCount("session-1") === 0);
    expect(runInputs[0]).toEqual(["after compact"]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
  });

  it("always-ask: registers a pending approval and pushes approval_request; continues after the decision", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    const events = capture("session-1");
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);

    const requests = serverEvents(events).filter((e) => e.type === "approval_request");
    expect(requests).toHaveLength(1);
    expect(
      (requests[0]!.toolCall as { payload: { tool_call_id: string } }).payload.tool_call_id,
    ).toBe("tc-1");
    expect(manager.pendingApprovals("session-1")).toHaveLength(1);

    expect(manager.decideApproval("session-1", "tc-404", "allow")).toBe(false);
    expect(manager.decideApproval("session-1", "tc-1", "allow")).toBe(true);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(manager.pendingApprovalCount("session-1")).toBe(0);
    const texts = recorded
      .filter((m) => (m.payload as { type?: string }).type === "text")
      .map((m) => (m.payload as { text: string }).text);
    expect(texts).toContain("decision=allow");
  });

  it("deny-all / read-only decide automatically (no human handoff)", async () => {
    sessions.updateApprovalMode("session-1", "deny-all");
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(
      recorded.some(
        (m) =>
          (m.payload as { type?: string; decision?: string }).type === "approval_decision" &&
          (m.payload as { decision: string }).decision === "deny",
      ),
    ).toBe(true);

    // read-only: read-only tools are auto-approved.
    recorded = [];
    sessions.updateApprovalMode("session-1", "read-only");
    const manager2 = makeManager(loaderOf(approvalFakeSession("session-1", "read_tool")));
    await manager2.startTask("session-1", [userText("go")]);
    await waitFor(() => manager2.statusOf("session-1") === "idle");
    expect(recorded.some((m) => (m.payload as { text?: string }).text === "decision=allow")).toBe(
      true,
    );
  });

  it("sub-session (origin) registration: session_meta persists; the title is left to the model using the sub-session's own conversation", async () => {
    const fake: RuntimeSession = {
      sessionId: "session-1",
      toolPermission: () => "rw",
      generateTitle: async () => ({ title: null, usage: null }),
      compactability: () => "ok" as const,
      steer: () => false,
      skipReconnectWait: () => false,
      async *run() {
        // The parent-level run_subagent call (no origin): its prompt becomes the sub-session title.
        yield toolCall({
          name: "run_subagent",
          arguments: JSON.stringify({ prompt: "Research the background of this question" }),
          toolCallId: "sub-1",
        });
        const hop = "child-1";
        yield withOrigin(
          sessionMeta({
            session_id: "child-1",
            model_id: "m-child",
            provider: "custom",
            model_context_window: 1000,
            system_prompt: "sys",
            tools: [],
            agent_state: "/root/p1/child_agent/agent_state",
            workspace: "/tmp/w-child",
          }),
          hop,
        );
        yield withOrigin(assistantText("child done"), hop);
        yield assistantText("done");
      },
      async *compact() {},
    };
    const notified: Array<{ ctx: UsageContext; req: TitleRequest }> = [];
    const manager = new SessionManager({
      sessions,
      channels,
      sources,
      loader: loaderOf(fake),
      recorder: { record: async () => {} },
      titles: {
        maybeGenerate: (ctx, _session, req) => notified.push({ ctx, req }),
      },
      log: () => {},
    });
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    const child = sessions.findById("child-1");
    expect(child).not.toBeNull();
    expect(child?.agentId).toBe("child_agent");
    expect(child?.modelId).toBe("m-child");
    expect(child?.workspace).toBe("/tmp/w-child");
    // The origin lands in the in-process registry (session_meta is the single source of
    // truth; the row stores no source column) — "subagent" even when the forwarded meta
    // predates the source field (this fake omits it): the registration path is the fallback.
    expect(sources.get("child-1")).toBe("subagent");
    expect(child && "source" in child).toBe(false);
    // Title left blank: produced by the title generator from the sub-session's own conversation (falls back to the prompt's first line on failure).
    expect(child?.title).toBeNull();

    await waitFor(() => notified.length === 2);
    const childTitle = notified.find((n) => n.ctx.sessionId === "child-1");
    expect(childTitle).toBeTruthy();
    // Explicit material override for the sub-session: user material = the prompt that spawned it; assistant material = the sub-session's **own** model output.
    expect(childTitle!.req.material).toEqual({
      userText: "Research the background of this question",
      assistantText: "child done",
    });
    expect(childTitle!.req.fallbackText).toBe("Research the background of this question");
    // Session/Agent record the sub-session, but modelId records the parent — the request runs on the parent's bare LLM.
    expect(childTitle!.ctx).toMatchObject({ agentId: "child_agent", modelId: "m1" });
    // The sub-session has no SSE channel of its own: title events are delivered over the parent session's channel.
    expect(childTitle!.req.notifyOn).toBe("session-1");
  });

  it("approval mode takes effect immediately: after a mid-run PATCH, the next decision uses the new mode", async () => {
    // A fake Session that requests approval twice.
    const fake: RuntimeSession = {
      sessionId: "session-1",
      toolPermission: () => "rw",
      generateTitle: async () => ({ title: null, usage: null }),
      compactability: () => "ok" as const,
      steer: () => false,
      skipReconnectWait: () => false,
      async *run(_input, opts) {
        const tc1 = toolCall({ name: "t1", arguments: "{}", toolCallId: "tc-1" });
        yield tc1;
        yield approvalDecision(await opts.approve(tc1), "tc-1");
        const tc2 = toolCall({ name: "t2", arguments: "{}", toolCallId: "tc-2" });
        yield tc2;
        yield approvalDecision(await opts.approve(tc2), "tc-2");
      },
      async *compact() {},
    };
    const manager = makeManager(loaderOf(fake));
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
    // Switch to allow-all while the first request is pending human review: the second no longer needs one.
    sessions.updateApprovalMode("session-1", "allow-all");
    manager.decideApproval("session-1", "tc-1", "deny");
    await waitFor(() => manager.statusOf("session-1") === "idle");
    const decisions = recorded
      .filter((m) => (m.payload as { type?: string }).type === "approval_decision")
      .map((m) => (m.payload as { decision: string }).decision);
    expect(decisions).toEqual(["deny", "allow"]);
    expect(manager.pendingApprovalCount("session-1")).toBe(0);
  });

  it("abort: pending approvals collapse to deny before the AbortSignal fires", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    expect(manager.abortTask("session-1")).toBe(false); // no Task in progress → no-op
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
    expect(manager.abortTask("session-1")).toBe(true);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    const payloads = recorded.map((m) => m.payload as { type?: string; decision?: string });
    expect(payloads.some((p) => p.type === "approval_decision" && p.decision === "deny")).toBe(
      true,
    );
    expect(payloads.some((p) => p.type === "abort")).toBe(true);
  });

  it("beginSessionDeletion: interrupts active runs, clears the active table and marks deleting (new Tasks 409); recovers after end", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    expect(manager.beginSessionDeletion("session-1")).toEqual([]); // no active entry
    // While deletion is in progress: a new Task is rejected with 409 (prevents resurrection).
    const rejected = await manager.startTask("session-1", [userText("x")]).catch((e: unknown) => e);
    expect((rejected as { status?: number }).status).toBe(409);
    manager.endSessionDeletion("session-1");
    // Runs normally once the deletion flag is cleared.
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
    const runnings = manager.beginSessionDeletion("session-1");
    expect(runnings.length).toBe(1);
    await Promise.allSettled(runnings);
    expect(manager.statusOf("session-1")).toBe("idle"); // entry has been removed
    manager.endSessionDeletion("session-1");
  });

  it("self-heal: when the loader returns a new session_id, the index primary key is updated and the actual current id returned", async () => {
    sessions.updateApprovalMode("session-1", "allow-all");
    const manager = makeManager(loaderOf(approvalFakeSession("session-2-healed")));
    const { sessionId } = await manager.startTask("session-1", [userText("go")]);
    expect(sessionId).toBe("session-2-healed");
    expect(sessions.findById("session-1")).toBeNull();
    expect(sessions.findById("session-2-healed")).not.toBeNull();
    await waitFor(() => manager.statusOf("session-2-healed") === "idle");
    // Usage is attributed under the new id.
    expect(recordedCtx[0]!.sessionId).toBe("session-2-healed");
  });

  it("after the channel is swept and rebuilt, drive still sends to the current channel (re-get before every publish)", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);

    // No subscribers, no publish while waiting for approval: simulate idle sweeping removing the old channel.
    channels.sweep(Date.now() + 60 * 60 * 1000);
    // Reconnect: hub.get creates a brand-new channel, and we subscribe to it.
    const events = capture("session-1");
    manager.decideApproval("session-1", "tc-1", "allow");
    await waitFor(() => manager.statusOf("session-1") === "idle");

    // The remaining output and task_state after approval must land on the new channel (the old reference should already be stale).
    const texts = events
      .filter((e) => e.event === undefined)
      .map((e) => (JSON.parse(e.data) as { payload: { type?: string; text?: string } }).payload)
      .filter((p) => p.type === "text")
      .map((p) => p.text);
    expect(texts).toContain("decision=allow");
    const states = serverEvents(events).filter((e) => e.type === "task_state");
    expect(states.map((s) => s.state)).toContain("idle");
  });

  it("abortProject: returns the in-flight drive Promises; wrap-up completes after awaiting them", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    expect(manager.abortProject("p1")).toEqual([]); // no active runs → empty array
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);

    const runnings = manager.abortProject("p1");
    expect(runnings).toHaveLength(1);
    await Promise.allSettled(runnings);
    // Wrap-up complete: the abort cleanup (abort event) has been written, and the entry has been removed from the active table.
    expect(recorded.some((m) => (m.payload as { type?: string }).type === "abort")).toBe(true);
    expect(manager.statusOf("session-1")).toBe("idle");
  });

  it("loader throwing HttpError (e.g. workspace_missing 409) → passed through as-is, not re-wrapped", async () => {
    const loader: SessionLoader = {
      load: async () => {
        throw new HttpError(409, "workspace_missing", "Workspace no longer exists.");
      },
    };
    const manager = makeManager(loader);
    const err = await manager.startTask("session-1", [userText("x")]).catch((e: unknown) => e);
    expect((err as { status: number; code: string }).status).toBe(409);
    expect((err as { code: string }).code).toBe("workspace_missing");
  });

  it("rejects new Tasks once shutdown is set (503 shutting_down)", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    await manager.shutdown();
    const err = await manager.startTask("session-1", [userText("x")]).catch((e: unknown) => e);
    expect((err as { status: number; code: string }).status).toBe(503);
    expect((err as { code: string }).code).toBe("shutting_down");
    const compactErr = await manager.startCompact("session-1").catch((e: unknown) => e);
    expect((compactErr as { status: number }).status).toBe(503);
  });

  it("sweepIdle: entries idle past the timeout are evicted (reloaded via the loader on next access)", async () => {
    let loads = 0;
    const loader: SessionLoader = {
      load: async () => {
        loads++;
        return approvalFakeSession("session-1");
      },
    };
    const manager = makeManager(loader);
    manager.adopt(ROW, approvalFakeSession("session-1"));
    // Not evicted before timeout: startTask reuses the active-table entry, bypassing the loader.
    manager.sweepIdle(Date.now() + 1000, 30 * 60 * 1000);
    sessions.updateApprovalMode("session-1", "allow-all");
    await manager.startTask("session-1", [userText("a")]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(loads).toBe(0);

    // Evicted after timeout: once the entry is released, the next startTask reloads it.
    manager.sweepIdle(Date.now() + 31 * 60 * 1000, 30 * 60 * 1000);
    await manager.startTask("session-1", [userText("b")]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(loads).toBe(1);
  });

  it("invalidateAgentRuntimes: an idle entry is discarded and re-resumed on next access; other Agents unaffected", async () => {
    let loads = 0;
    const loader: SessionLoader = {
      load: async () => {
        loads++;
        return approvalFakeSession("session-1");
      },
    };
    const manager = makeManager(loader);
    sessions.updateApprovalMode("session-1", "allow-all");
    // Adopted after creation: records the current generation, so Tasks reuse it without loading.
    manager.adopt(ROW, approvalFakeSession("session-1"));

    // Another Agent's vault update leaves this entry alone.
    manager.invalidateAgentRuntimes("p1", "other_agent");
    await manager.startTask("session-1", [userText("a")]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(loads).toBe(0);

    // This Agent's vault update: the next Task rebuilds the runtime via the loader.
    manager.invalidateAgentRuntimes("p1", "a1");
    await manager.startTask("session-1", [userText("b")]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(loads).toBe(1);

    // Rebuilt once only: the fresh entry is current again.
    await manager.startTask("session-1", [userText("c")]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(loads).toBe(1);
  });

  it("invalidateAgentRuntimes mid-run: the in-flight Task keeps its runtime; the first Task after it finishes re-resumes", async () => {
    let loads = 0;
    const loader: SessionLoader = {
      load: async () => {
        loads++;
        return approvalFakeSession("session-1");
      },
    };
    const manager = makeManager(loader);
    await manager.startTask("session-1", [userText("go")]); // built here: load #1
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);

    manager.invalidateAgentRuntimes("p1", "a1"); // vault updated while the Task waits on approval
    // The pending approval still targets the live entry: the run completes on the old runtime.
    expect(manager.decideApproval("session-1", "tc-1", "allow")).toBe(true);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(loads).toBe(1);

    // First Task after it finished: the stale entry is discarded and re-resumed with current values.
    sessions.updateApprovalMode("session-1", "allow-all");
    await manager.startTask("session-1", [userText("next")]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    expect(loads).toBe(2);
  });

  it("invalidateProjectRuntimes: every cached entry in the Project is rebuilt; other Projects unaffected", async () => {
    // Two Agents with cached runtimes in p1, one in p2 — a models/credential update to p1
    // must reach BOTH of p1's Agents (collected from the active table) and leave p2 alone.
    const rowA2: SessionRow = { ...ROW, sessionId: "session-2", agentId: "a2" };
    const rowP2: SessionRow = { ...ROW, sessionId: "session-3", projectId: "p2" };
    sessions.insert(rowA2);
    sessions.insert(rowP2);
    let loads = 0;
    const loader: SessionLoader = {
      load: async (row) => {
        loads++;
        return approvalFakeSession(row.sessionId);
      },
    };
    const manager = makeManager(loader);
    for (const sid of ["session-1", "session-2", "session-3"]) {
      sessions.updateApprovalMode(sid, "allow-all");
    }
    manager.adopt(ROW, approvalFakeSession("session-1"));
    manager.adopt(rowA2, approvalFakeSession("session-2"));
    manager.adopt(rowP2, approvalFakeSession("session-3"));

    manager.invalidateProjectRuntimes("p1");
    // Both p1 entries are stale and rebuild through the loader on their next Task.
    await manager.startTask("session-1", [userText("a")]);
    await waitFor(() => manager.statusOf("session-1") === "idle");
    await manager.startTask("session-2", [userText("b")]);
    await waitFor(() => manager.statusOf("session-2") === "idle");
    expect(loads).toBe(2);
    // The p2 entry keeps its cached runtime.
    await manager.startTask("session-3", [userText("c")]);
    await waitFor(() => manager.statusOf("session-3") === "idle");
    expect(loads).toBe(2);
  });

  it("sweepIdle: entries that are running / have pending approvals are not evicted", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    await manager.startTask("session-1", [userText("go")]);
    await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
    manager.sweepIdle(Date.now() + 24 * 60 * 60 * 1000, 30 * 60 * 1000);
    // The entry is still there: the approval can be decided and wraps up normally.
    expect(manager.statusOf("session-1")).toBe("running");
    expect(manager.decideApproval("session-1", "tc-1", "allow")).toBe(true);
    await waitFor(() => manager.statusOf("session-1") === "idle");
  });

  it("compact: sets compacting, output goes to the channel, returns to idle at the end", async () => {
    const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
    const events = capture("session-1");
    await manager.startCompact("session-1");
    await waitFor(() => manager.statusOf("session-1") === "idle" && recorded.length >= 2);
    const states = serverEvents(events).filter((e) => e.type === "task_state");
    expect(states.map((s) => s.state)).toEqual(["compacting", "idle"]);
    expect(recorded.map((m) => (m.payload as { type: string }).type)).toEqual([
      "compaction_begin",
      "compaction_end",
    ]);
  });

  it("notifies title generation after a Task completes: fallback material is the user text, generation material is self-collected by the Session; compaction doesn't notify", async () => {
    const notified: { ctx: UsageContext; session: unknown; req: TitleRequest }[] = [];
    const plainSession: RuntimeSession = {
      sessionId: "session-1",
      toolPermission: () => "rw",
      generateTitle: async () => ({ title: null, usage: null }),
      compactability: () => "ok" as const,
      steer: () => false,
      skipReconnectWait: () => false,
      async *run() {
        yield thinkingMessage("thinking");
        yield assistantText("answer A");
        yield withOrigin(assistantText("sub-session text"), "session-sub");
        yield assistantText("answer B");
      },
      async *compact() {
        yield compactionBegin({ reason: "manual", mode: "summarize", context: 1, turns: 1 });
        yield compactionEnd({ reason: "manual", mode: "summarize", status: "completed" });
      },
    };
    const manager = new SessionManager({
      sessions,
      channels,
      sources,
      loader: loaderOf(plainSession),
      recorder: { record: async () => {} },
      titles: {
        maybeGenerate: (ctx, session, req) => notified.push({ ctx, session, req }),
      },
      log: () => {},
    });

    await manager.startTask("session-1", [userText("question 1"), userText("question 2")]);
    await waitFor(() => notified.length === 1);
    expect(notified[0]!.req.fallbackText).toBe("question 1\nquestion 2");
    // No material override for the main session: material is gathered by the core Session during run.
    expect(notified[0]!.req.material).toBeUndefined();
    expect(notified[0]!.session).toBe(plainSession);
    expect(notified[0]!.ctx).toMatchObject({
      projectId: "p1",
      agentId: "a1",
      sessionId: "session-1",
      modelId: "m1",
      provider: "custom",
    });

    await manager.startCompact("session-1");
    await waitFor(() => manager.statusOf("session-1") === "idle");
    await new Promise((r) => setTimeout(r, 10));
    expect(notified.length).toBe(1);
  });

  it("fires title generation early once 1000 chars of body text have streamed (mid-run, before the Task finishes)", async () => {
    const notified: { ctx: UsageContext; req: TitleRequest }[] = [];
    // Gate the run after the big body text so the early trigger provably happens mid-run.
    let release!: () => void;
    const gate = new Promise<void>((r) => (release = r));
    const longSession: RuntimeSession = {
      sessionId: "session-1",
      toolPermission: () => "rw",
      generateTitle: async () => ({ title: null, usage: null }),
      compactability: () => "ok" as const,
      steer: () => false,
      skipReconnectWait: () => false,
      async *run() {
        yield assistantText("x".repeat(600));
        // Sub-session text doesn't count toward the main-session body threshold
        // (asserted on its own in the next test).
        yield withOrigin(assistantText("z".repeat(2000)), "session-sub");
        yield assistantText("y".repeat(600)); // crosses the 1000-char threshold
        await gate;
        yield assistantText("tail");
      },
      async *compact() {},
    };
    const manager = new SessionManager({
      sessions,
      channels,
      sources,
      loader: loaderOf(longSession),
      recorder: { record: async () => {} },
      titles: {
        maybeGenerate: (ctx, _session, req) => notified.push({ ctx, req }),
      },
      log: () => {},
    });

    await manager.startTask("session-1", [userText("long question")]);
    // The early trigger fires while the run is still in flight (gated before completion).
    await waitFor(() => notified.length === 1);
    expect(manager.statusOf("session-1")).toBe("running");
    expect(notified[0]!.req.fallbackText).toBe("long question");
    expect(notified[0]!.req.material).toBeUndefined();

    // Completion still notifies as the short-answer fallback path (the generator itself dedups).
    release();
    await waitFor(() => manager.statusOf("session-1") === "idle");
    await waitFor(() => notified.length === 2);
  });

  it("a subagent's output never fires the early title: only main-session body text counts toward the 1000 chars", async () => {
    const notified: { ctx: UsageContext; req: TitleRequest }[] = [];
    const driven: OmniMessage[] = [];
    // Gate the run right after the sub-session's long output: while the parent is parked
    // here the completion trigger hasn't run yet, so an empty `notified` proves the child's
    // text alone never crossed the threshold (without the gate an early fire would be
    // indistinguishable from the completion one).
    let release!: () => void;
    const gate = new Promise<void>((r) => (release = r));
    const delegating: RuntimeSession = {
      sessionId: "session-1",
      toolPermission: () => "rw",
      generateTitle: async () => ({ title: null, usage: null }),
      compactability: () => "ok" as const,
      steer: () => false,
      skipReconnectWait: () => false,
      async *run() {
        yield toolCall({
          name: "run_subagent",
          arguments: JSON.stringify({ prompt: "Research the background of this question" }),
          toolCallId: "sub-1",
        });
        const hop = "child-1";
        yield withOrigin(
          sessionMeta({
            session_id: "child-1",
            model_id: "m-child",
            provider: "custom",
            model_context_window: 1000,
            system_prompt: "sys",
            tools: [],
            agent_state: "/root/p1/child_agent/agent_state",
            workspace: "/tmp/w-child",
          }),
          hop,
        );
        // Far past the threshold, but it belongs to the sub-session's own conversation.
        yield withOrigin(assistantText("z".repeat(3000)), hop);
        await gate;
        yield assistantText("short answer"); // the parent's whole body, well under 1000 chars
      },
      async *compact() {},
    };
    const manager = new SessionManager({
      sessions,
      channels,
      sources,
      loader: loaderOf(delegating),
      recorder: {
        record: async (_ctx, msg) => {
          driven.push(msg);
        },
      },
      titles: {
        maybeGenerate: (ctx, _session, req) => notified.push({ ctx, req }),
      },
      log: () => {},
    });

    await manager.startTask("session-1", [userText("delegate this")]);
    // Recording happens after the early-title check, so once the sub-session's 3000 chars
    // have been driven through the parent's counter has already seen everything it will
    // ever see from the child — and it must still be at zero.
    await waitFor(() => driven.length === 3);
    expect(manager.statusOf("session-1")).toBe("running");
    expect(notified.length).toBe(0);

    // Only the completion path notifies: once for the parent, once for the sub-session's own title.
    release();
    await waitFor(() => manager.statusOf("session-1") === "idle");
    await waitFor(() => notified.length === 2);
    expect(notified.map((n) => n.ctx.sessionId)).toEqual(["session-1", "child-1"]);
  });
});
