import type {
  ChatMessage,
  Logger,
  Span,
  StepId,
  StorageNamespace,
  TurnMetrics,
} from "@dispatch/kernel";
import { beforeEach, describe, expect, it } from "vitest";
import { CONVERSATION_INDEX_KEY, chunkKey, metaKey } from "./keys.js";
import { createConversationStore, extractTitle } from "./store.js";

interface SpanEvent {
  readonly kind: "span-open" | "span-close";
  readonly name: string;
  readonly attrs?: Record<string, string | number | boolean | null> | undefined;
  readonly conversationId?: string | undefined;
}

function createCapturingLogger(): { logger: Logger; events: SpanEvent[] } {
  const events: SpanEvent[] = [];

  function createSpan(name: string, conversationId?: string | undefined): Span {
    events.push({ kind: "span-open", name, conversationId });
    const span: Span = {
      id: `span_${events.length}`,
      log: createFakeLogger(conversationId),
      setAttributes: () => {},
      addLink: () => {},
      child: (childName, attrs) => {
        const child = createSpan(childName, conversationId);
        if (attrs !== undefined) {
          const prev = events[events.length - 1];
          if (prev !== undefined) {
            events[events.length - 1] = {
              ...prev,
              attrs: attrs as Record<string, string | number | boolean | null>,
            };
          }
        }
        return child;
      },
      end: (outcome) => {
        const attrs = outcome?.attrs as
          | Record<string, string | number | boolean | null>
          | undefined;
        events.push({ kind: "span-close", name, attrs, conversationId });
      },
    };
    return span;
  }

  function createFakeLogger(conversationId?: string | undefined): Logger {
    return {
      debug: () => {},
      info: () => {},
      warn: () => {},
      error: () => {},
      child: (ctx) => createFakeLogger(ctx.conversationId ?? conversationId),
      span: (name, attrs) => {
        const span = createSpan(name, conversationId);
        if (attrs !== undefined) {
          const prev = events[events.length - 1];
          if (prev !== undefined) {
            events[events.length - 1] = {
              ...prev,
              attrs: attrs as Record<string, string | number | boolean | null>,
            };
          }
        }
        return span;
      },
    };
  }

  return { logger: createFakeLogger(), events };
}

function createMemoryStorage(): StorageNamespace {
  const data = new Map<string, string>();
  return {
    get: async (key) => data.get(key) ?? null,
    set: async (key, value) => {
      data.set(key, value);
    },
    delete: async (key) => {
      data.delete(key);
    },
    has: async (key) => data.has(key),
    keys: async (prefix) => {
      const all = [...data.keys()];
      if (!prefix) return all;
      return all.filter((k) => k.startsWith(prefix));
    },
  };
}

describe("ConversationStore", () => {
  let storage: StorageNamespace;

  beforeEach(() => {
    storage = createMemoryStorage();
  });

  it("returns empty array for unknown conversation", async () => {
    const store = createConversationStore(storage);
    const result = await store.load("nonexistent");
    expect(result).toEqual([]);
  });

  it("round-trips a single message", async () => {
    const store = createConversationStore(storage);
    const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] };
    await store.append("conv1", [msg]);
    const result = await store.load("conv1");
    expect(result).toEqual([msg]);
  });

  it("round-trips multiple messages in one append", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "hi" }] },
      { role: "assistant", chunks: [{ type: "text", text: "hello" }] },
    ];
    await store.append("conv1", messages);
    const result = await store.load("conv1");
    expect(result).toEqual(messages);
  });

  it("accumulates messages across multiple appends", async () => {
    const store = createConversationStore(storage);
    const turn1: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "turn 1" }] },
      { role: "assistant", chunks: [{ type: "text", text: "reply 1" }] },
    ];
    const turn2: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "turn 2" }] },
      { role: "assistant", chunks: [{ type: "text", text: "reply 2" }] },
    ];
    await store.append("conv1", turn1);
    await store.append("conv1", turn2);
    const result = await store.load("conv1");
    expect(result).toEqual([...turn1, ...turn2]);
  });

  it("preserves message ordering", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [];
    for (let i = 0; i < 10; i++) {
      messages.push({ role: "user", chunks: [{ type: "text", text: `msg ${i}` }] });
    }
    await store.append("conv1", messages);
    const result = await store.load("conv1");
    expect(result).toEqual(messages);
    for (let i = 0; i < 10; i++) {
      const chunk = result[i]?.chunks[0];
      expect(chunk?.type === "text" ? chunk.text : null).toBe(`msg ${i}`);
    }
  });

  it("isolates conversations by id", async () => {
    const store = createConversationStore(storage);
    const msgA: ChatMessage = { role: "user", chunks: [{ type: "text", text: "A" }] };
    const msgB: ChatMessage = { role: "user", chunks: [{ type: "text", text: "B" }] };
    await store.append("convA", [msgA]);
    await store.append("convB", [msgB]);
    expect(await store.load("convA")).toEqual([msgA]);
    expect(await store.load("convB")).toEqual([msgB]);
  });

  it("reconciles orphaned tool-calls on load", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "do it" }] },
      {
        role: "assistant",
        chunks: [
          {
            type: "tool-call",
            toolCallId: "call_1",
            toolName: "someTool",
            input: {},
          },
        ],
      },
    ];
    await store.append("conv1", messages);
    const result = await store.load("conv1");
    expect(result).toHaveLength(3);
    expect(result[2]?.role).toBe("tool");
    const chunk = result[2]?.chunks[0];
    expect(chunk?.type === "tool-result" ? chunk.isError : null).toBe(true);
  });

  it("handles tool-call/tool-result round-trip", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      {
        role: "assistant",
        chunks: [
          {
            type: "tool-call",
            toolCallId: "call_1",
            toolName: "readFile",
            input: { path: "/tmp/x" },
          },
        ],
      },
      {
        role: "tool",
        chunks: [
          {
            type: "tool-result",
            toolCallId: "call_1",
            toolName: "readFile",
            content: "contents",
            isError: false,
          },
        ],
      },
    ];
    await store.append("conv1", messages);
    const result = await store.load("conv1");
    expect(result).toEqual(messages);
  });

  it("append assigns gap-free 1-based per-chunk seq", async () => {
    const store = createConversationStore(storage);
    const msg: ChatMessage = {
      role: "assistant",
      chunks: [
        { type: "text", text: "first" },
        { type: "thinking", text: "hmm" },
        { type: "text", text: "second" },
      ],
    };
    await store.append("conv1", [msg]);
    const chunks = await store.loadSince("conv1");
    expect(chunks).toHaveLength(3);
    expect(chunks[0]?.seq).toBe(1);
    expect(chunks[1]?.seq).toBe(2);
    expect(chunks[2]?.seq).toBe(3);
  });

  it("seq continues monotonically across separate append calls", async () => {
    const store = createConversationStore(storage);
    const msg1: ChatMessage = {
      role: "user",
      chunks: [
        { type: "text", text: "a" },
        { type: "text", text: "b" },
      ],
    };
    const msg2: ChatMessage = {
      role: "assistant",
      chunks: [
        { type: "text", text: "c" },
        { type: "text", text: "d" },
        { type: "text", text: "e" },
      ],
    };
    await store.append("conv1", [msg1]);
    await store.append("conv1", [msg2]);
    const chunks = await store.loadSince("conv1");
    expect(chunks).toHaveLength(5);
    expect(chunks[0]?.seq).toBe(1);
    expect(chunks[1]?.seq).toBe(2);
    expect(chunks[2]?.seq).toBe(3);
    expect(chunks[3]?.seq).toBe(4);
    expect(chunks[4]?.seq).toBe(5);
  });

  it("loadSince() returns every StoredChunk ascending by seq, carrying role + chunk", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "hello" }] },
      { role: "assistant", chunks: [{ type: "text", text: "world" }] },
    ];
    await store.append("conv1", messages);
    const chunks = await store.loadSince("conv1");
    expect(chunks).toHaveLength(2);
    expect(chunks[0]?.seq).toBe(1);
    expect(chunks[0]?.role).toBe("user");
    expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" });
    expect(chunks[1]?.seq).toBe(2);
    expect(chunks[1]?.role).toBe("assistant");
    expect(chunks[1]?.chunk).toEqual({ type: "text", text: "world" });
  });

  it("loadSince(sinceSeq=N) returns only chunks with seq > N", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "a" }] },
      { role: "assistant", chunks: [{ type: "text", text: "b" }] },
      { role: "user", chunks: [{ type: "text", text: "c" }] },
    ];
    await store.append("conv1", messages);
    const chunks = await store.loadSince("conv1", 2);
    expect(chunks).toHaveLength(1);
    expect(chunks[0]?.seq).toBe(3);
    expect(chunks[0]?.role).toBe("user");
    expect(chunks[0]?.chunk).toEqual({ type: "text", text: "c" });
  });

  it("loadSince treats a non-positive / non-integer sinceSeq as 0 (from the start), honoring the contract", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "a" }] },
      { role: "assistant", chunks: [{ type: "text", text: "b" }] },
      { role: "user", chunks: [{ type: "text", text: "c" }] },
    ];
    await store.append("conv1", messages);
    const all = [1, 2, 3];
    // Non-positive integers → from the start (already worked; now codified).
    expect((await store.loadSince("conv1", 0)).map((c) => c.seq)).toEqual(all);
    expect((await store.loadSince("conv1", -2)).map((c) => c.seq)).toEqual(all);
    // Non-integer values → from the start (the contract lie this fixes:
    // a positive non-integer like 2.5 used to filter like sinceSeq=2).
    expect((await store.loadSince("conv1", 2.5)).map((c) => c.seq)).toEqual(all);
    expect((await store.loadSince("conv1", 2.7)).map((c) => c.seq)).toEqual(all);
    expect((await store.loadSince("conv1", -2.5)).map((c) => c.seq)).toEqual(all);
    expect((await store.loadSince("conv1", Number.POSITIVE_INFINITY)).map((c) => c.seq)).toEqual(
      all,
    );
    expect((await store.loadSince("conv1", Number.NaN)).map((c) => c.seq)).toEqual(all);
  });

  it("load() round-trips the exact ChatMessage[] that was appended", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "read file" }] },
      {
        role: "assistant",
        chunks: [
          { type: "thinking", text: "let me think" },
          { type: "text", text: "I will read it" },
          {
            type: "tool-call",
            toolCallId: "call_rt",
            toolName: "readFile",
            input: { path: "/tmp/x" },
          },
        ],
      },
      {
        role: "tool",
        chunks: [
          {
            type: "tool-result",
            toolCallId: "call_rt",
            toolName: "readFile",
            content: "file contents here",
            isError: false,
          },
        ],
      },
    ];
    await store.append("conv1", messages);
    const result = await store.load("conv1");
    expect(result).toEqual(messages);
  });

  it("load() does not merge consecutive same-role messages", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "first user msg" }] },
      { role: "user", chunks: [{ type: "text", text: "second user msg" }] },
      { role: "assistant", chunks: [{ type: "text", text: "reply" }] },
    ];
    await store.append("conv1", messages);
    const result = await store.load("conv1");
    expect(result).toHaveLength(3);
    expect(result).toEqual(messages);
    expect(result[0]?.chunks[0]?.type === "text" ? result[0]?.chunks[0]?.text : null).toBe(
      "first user msg",
    );
    expect(result[1]?.chunks[0]?.type === "text" ? result[1]?.chunks[0]?.text : null).toBe(
      "second user msg",
    );
  });

  it("reconcile still synthesizes a result for an interrupted tool-call on load", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "do it" }] },
      {
        role: "assistant",
        chunks: [
          { type: "text", text: "calling tool" },
          {
            type: "tool-call",
            toolCallId: "call_orphan",
            toolName: "someTool",
            input: { x: 1 },
          },
        ],
      },
    ];
    await store.append("conv1", messages);
    const result = await store.load("conv1");
    expect(result).toHaveLength(3);
    expect(result[2]?.role).toBe("tool");
    const chunk = result[2]?.chunks[0];
    if (chunk === undefined) throw new Error("expected chunk");
    expect(chunk.type).toBe("tool-result");
    if (chunk.type === "tool-result") {
      expect(chunk.toolCallId).toBe("call_orphan");
      expect(chunk.isError).toBe(true);
      expect(chunk.content).toBe("interrupted: tool execution did not complete");
    }
  });

  it("load() skips a corrupt-JSON chunk row and reconciles the rest (no throw)", async () => {
    // "Never leave the system broken": a single bad row must not brick the
    // conversation. The corrupt chunk is skipped; the rest loads and reconcile
    // still runs normally. Fake only the OUTERMOST edge (the injected storage)
    // — no @dispatch/* mocks.
    const { logger } = createCapturingLogger();
    const store = createConversationStore(storage, logger);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "do it" }] },
      {
        role: "assistant",
        chunks: [
          { type: "text", text: "calling" },
          { type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} },
        ],
      },
    ];
    await store.append("conv_corrupt", messages);
    // Corrupt the assistant text chunk (seq 2) directly in storage.
    await storage.set(chunkKey("conv_corrupt", 2), "{this is not valid json");

    const result = await store.load("conv_corrupt");
    // No throw. The user message survives; the assistant message keeps its
    // tool-call (its text chunk was the corrupt row, skipped); reconcile
    // synthesizes the missing tool-result for the now-orphaned tool-call.
    expect(result).toEqual([
      { role: "user", chunks: [{ type: "text", text: "do it" }] },
      {
        role: "assistant",
        chunks: [{ type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} }],
      },
      {
        role: "tool",
        chunks: [
          {
            type: "tool-result",
            toolCallId: "call_x",
            toolName: "t",
            content: "interrupted: tool execution did not complete",
            isError: true,
          },
        ],
      },
    ]);
  });

  it("loadSince returns empty array for unknown conversation", async () => {
    const store = createConversationStore(storage);
    const result = await store.loadSince("nonexistent");
    expect(result).toEqual([]);
  });

  it("loadSince(0) returns all chunks", async () => {
    const store = createConversationStore(storage);
    const msg: ChatMessage = {
      role: "user",
      chunks: [
        { type: "text", text: "a" },
        { type: "text", text: "b" },
      ],
    };
    await store.append("conv1", [msg]);
    const all = await store.loadSince("conv1", 0);
    expect(all).toHaveLength(2);
    expect(all[0]?.seq).toBe(1);
    expect(all[1]?.seq).toBe(2);
  });

  it("append → loadSince preserves a tool chunk's stepId", async () => {
    const store = createConversationStore(storage);
    const stepId = "step_abc" as StepId;
    const messages: ChatMessage[] = [
      {
        role: "assistant",
        chunks: [
          {
            type: "tool-call",
            toolCallId: "call_sid",
            toolName: "myTool",
            input: {},
            stepId,
          },
        ],
      },
      {
        role: "tool",
        chunks: [
          {
            type: "tool-result",
            toolCallId: "call_sid",
            toolName: "myTool",
            content: "ok",
            isError: false,
            stepId,
          },
        ],
      },
    ];
    await store.append("conv1", messages);
    const chunks = await store.loadSince("conv1");
    expect(chunks).toHaveLength(2);
    const callChunk = chunks[0]?.chunk;
    expect(callChunk?.type).toBe("tool-call");
    if (callChunk?.type === "tool-call") {
      expect(callChunk.stepId).toBe(stepId);
    }
    const resultChunk = chunks[1]?.chunk;
    expect(resultChunk?.type).toBe("tool-result");
    if (resultChunk?.type === "tool-result") {
      expect(resultChunk.stepId).toBe(stepId);
    }
  });

  it("load preserves a tool chunk's stepId", async () => {
    const store = createConversationStore(storage);
    const stepId = "step_xyz" as StepId;
    const messages: ChatMessage[] = [
      {
        role: "assistant",
        chunks: [
          {
            type: "tool-call",
            toolCallId: "call_lid",
            toolName: "myTool",
            input: { a: 1 },
            stepId,
          },
        ],
      },
      {
        role: "tool",
        chunks: [
          {
            type: "tool-result",
            toolCallId: "call_lid",
            toolName: "myTool",
            content: "done",
            isError: false,
            stepId,
          },
        ],
      },
    ];
    await store.append("conv1", messages);
    const result = await store.load("conv1");
    expect(result).toHaveLength(2);
    const callChunk = result[0]?.chunks[0];
    expect(callChunk?.type).toBe("tool-call");
    if (callChunk?.type === "tool-call") {
      expect(callChunk.stepId).toBe(stepId);
    }
    const resultChunk = result[1]?.chunks[0];
    expect(resultChunk?.type).toBe("tool-result");
    if (resultChunk?.type === "tool-result") {
      expect(resultChunk.stepId).toBe(stepId);
    }
  });
});

describe("ConversationStore loadSince windowing", () => {
  let storage: StorageNamespace;

  beforeEach(() => {
    storage = createMemoryStorage();
  });

  // Append `count` single-chunk user messages so seq runs 1..count, gap-free.
  async function seed(store: ReturnType<typeof createConversationStore>, count: number) {
    const messages: ChatMessage[] = [];
    for (let i = 1; i <= count; i++) {
      messages.push({ role: "user", chunks: [{ type: "text", text: `m${i}` }] });
    }
    await store.append("conv1", messages);
  }

  it("limit returns the newest N of the selection, ascending by seq", async () => {
    const store = createConversationStore(storage);
    await seed(store, 5);
    const chunks = await store.loadSince("conv1", 0, { limit: 2 });
    expect(chunks.map((c) => c.seq)).toEqual([4, 5]);
  });

  it("limit >= selection size returns the whole selection (exact, not truncated)", async () => {
    const store = createConversationStore(storage);
    await seed(store, 3);
    const exactlyAll = await store.loadSince("conv1", 0, { limit: 3 });
    expect(exactlyAll.map((c) => c.seq)).toEqual([1, 2, 3]);
    const overAll = await store.loadSince("conv1", 0, { limit: 99 });
    expect(overAll.map((c) => c.seq)).toEqual([1, 2, 3]);
  });

  it("beforeSeq bounds the selection exclusively (seq < beforeSeq)", async () => {
    const store = createConversationStore(storage);
    await seed(store, 5);
    const chunks = await store.loadSince("conv1", 0, { beforeSeq: 3 });
    expect(chunks.map((c) => c.seq)).toEqual([1, 2]);
  });

  it("sinceSeq + beforeSeq combine to sinceSeq < seq < beforeSeq", async () => {
    const store = createConversationStore(storage);
    await seed(store, 6);
    const chunks = await store.loadSince("conv1", 2, { beforeSeq: 5 });
    expect(chunks.map((c) => c.seq)).toEqual([3, 4]);
  });

  it("beforeSeq + limit: newest N below the bound, ascending (page older history in)", async () => {
    const store = createConversationStore(storage);
    await seed(store, 8);
    const chunks = await store.loadSince("conv1", 0, { beforeSeq: 6, limit: 2 });
    expect(chunks.map((c) => c.seq)).toEqual([4, 5]);
  });

  it("empty selection returns [] (beforeSeq=1, and sinceSeq past the tail)", async () => {
    const store = createConversationStore(storage);
    await seed(store, 4);
    expect(await store.loadSince("conv1", 0, { beforeSeq: 1 })).toEqual([]);
    expect(await store.loadSince("conv1", 4, { limit: 3 })).toEqual([]);
  });

  it("non-positive / non-integer limit and beforeSeq are treated as absent", async () => {
    const store = createConversationStore(storage);
    await seed(store, 4);
    const all = [1, 2, 3, 4];
    expect((await store.loadSince("conv1", 0, { limit: 0 })).map((c) => c.seq)).toEqual(all);
    expect((await store.loadSince("conv1", 0, { limit: -2 })).map((c) => c.seq)).toEqual(all);
    expect((await store.loadSince("conv1", 0, { limit: 1.5 })).map((c) => c.seq)).toEqual(all);
    expect((await store.loadSince("conv1", 0, { beforeSeq: 0 })).map((c) => c.seq)).toEqual(all);
    expect((await store.loadSince("conv1", 0, { beforeSeq: -3 })).map((c) => c.seq)).toEqual(all);
    expect((await store.loadSince("conv1", 0, { beforeSeq: 2.7 })).map((c) => c.seq)).toEqual(all);
  });

  it("window omitted is identical to today's behavior (regression guard)", async () => {
    const store = createConversationStore(storage);
    await seed(store, 5);
    const base = await store.loadSince("conv1", 1);
    const withEmptyWindow = await store.loadSince("conv1", 1, {});
    // A caller whose window fields happen to be undefined (e.g. unset query
    // params) — modelled as an optional-field record, not explicit `undefined`
    // literals (which exactOptionalPropertyTypes rejects on the contract).
    const undefinedFieldsWindow: { beforeSeq?: number; limit?: number } = {};
    const withUndefinedFields = await store.loadSince("conv1", 1, undefinedFieldsWindow);
    expect(base.map((c) => c.seq)).toEqual([2, 3, 4, 5]);
    expect(withEmptyWindow).toEqual(base);
    expect(withUndefinedFields).toEqual(base);
  });
});

describe("ConversationStore metrics", () => {
  let storage: StorageNamespace;

  beforeEach(() => {
    storage = createMemoryStorage();
  });

  it("appendMetrics → loadMetrics round-trips a TurnMetrics (usage + durationMs + steps)", async () => {
    const store = createConversationStore(storage);
    const stepId = "step_1" as StepId;
    const metrics: TurnMetrics = {
      turnId: "turn_abc",
      usage: { inputTokens: 100, outputTokens: 50 },
      durationMs: 1234,
      steps: [
        {
          stepId,
          usage: { inputTokens: 100, outputTokens: 50 },
          ttftMs: 200,
          decodeMs: 800,
          genTotalMs: 1000,
        },
      ],
    };
    await store.appendMetrics("conv1", metrics);
    const result = await store.loadMetrics("conv1");
    expect(result).toHaveLength(1);
    expect(result[0]).toEqual(metrics);
  });

  it("loadMetrics returns turns in append order", async () => {
    const store = createConversationStore(storage);
    const metrics1: TurnMetrics = {
      turnId: "turn_first",
      usage: { inputTokens: 10, outputTokens: 5 },
      steps: [],
    };
    const metrics2: TurnMetrics = {
      turnId: "turn_second",
      usage: { inputTokens: 20, outputTokens: 10 },
      steps: [],
    };
    const metrics3: TurnMetrics = {
      turnId: "turn_third",
      usage: { inputTokens: 30, outputTokens: 15 },
      steps: [],
    };
    await store.appendMetrics("conv1", metrics1);
    await store.appendMetrics("conv1", metrics2);
    await store.appendMetrics("conv1", metrics3);
    const result = await store.loadMetrics("conv1");
    expect(result).toHaveLength(3);
    expect(result[0]?.turnId).toBe("turn_first");
    expect(result[1]?.turnId).toBe("turn_second");
    expect(result[2]?.turnId).toBe("turn_third");
  });

  it("loadMetrics returns [] for a conversation with no persisted metrics", async () => {
    const store = createConversationStore(storage);
    const result = await store.loadMetrics("nonexistent");
    expect(result).toEqual([]);
  });

  it("appendMetrics does not affect chunk load / loadSince", async () => {
    const store = createConversationStore(storage);
    const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] };
    await store.append("conv1", [msg]);

    const metrics: TurnMetrics = {
      turnId: "turn_iso",
      usage: { inputTokens: 100, outputTokens: 50 },
      steps: [],
    };
    await store.appendMetrics("conv1", metrics);

    const messages = await store.load("conv1");
    expect(messages).toEqual([msg]);

    const chunks = await store.loadSince("conv1");
    expect(chunks).toHaveLength(1);
    expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" });
  });

  it("TurnMetrics with cache tokens + per-step ttft/decode/genTotal round-trips losslessly", async () => {
    const store = createConversationStore(storage);
    const stepId1 = "step_a" as StepId;
    const stepId2 = "step_b" as StepId;
    const metrics: TurnMetrics = {
      turnId: "turn_cache",
      usage: {
        inputTokens: 500,
        outputTokens: 200,
        cacheReadTokens: 300,
        cacheWriteTokens: 100,
      },
      durationMs: 5000,
      steps: [
        {
          stepId: stepId1,
          usage: {
            inputTokens: 300,
            outputTokens: 100,
            cacheReadTokens: 200,
            cacheWriteTokens: 50,
          },
          ttftMs: 150,
          decodeMs: 600,
          genTotalMs: 750,
        },
        {
          stepId: stepId2,
          usage: {
            inputTokens: 200,
            outputTokens: 100,
            cacheReadTokens: 100,
            cacheWriteTokens: 50,
          },
          ttftMs: 100,
          decodeMs: 400,
          genTotalMs: 500,
        },
      ],
    };
    await store.appendMetrics("conv1", metrics);
    const result = await store.loadMetrics("conv1");
    expect(result).toHaveLength(1);
    expect(result[0]).toEqual(metrics);
    expect(result[0]?.usage.cacheReadTokens).toBe(300);
    expect(result[0]?.usage.cacheWriteTokens).toBe(100);
    expect(result[0]?.steps[0]?.ttftMs).toBe(150);
    expect(result[0]?.steps[0]?.decodeMs).toBe(600);
    expect(result[0]?.steps[0]?.genTotalMs).toBe(750);
    expect(result[0]?.steps[1]?.ttftMs).toBe(100);
    expect(result[0]?.steps[1]?.decodeMs).toBe(400);
    expect(result[0]?.steps[1]?.genTotalMs).toBe(500);
  });
});

describe("ConversationStore reconcile.repair span", () => {
  let storage: StorageNamespace;

  beforeEach(() => {
    storage = createMemoryStorage();
  });

  it("load() emits a reconcile.repair span when a dangling tool-call is repaired", async () => {
    const { logger, events } = createCapturingLogger();
    const store = createConversationStore(storage, logger);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "do it" }] },
      {
        role: "assistant",
        chunks: [
          {
            type: "tool-call",
            toolCallId: "call_dangle",
            toolName: "someTool",
            input: {},
          },
        ],
      },
    ];
    await store.append("conv_span", messages);
    await store.load("conv_span");

    const spanOpens = events.filter((e) => e.kind === "span-open" && e.name === "reconcile.repair");
    const spanCloses = events.filter(
      (e) => e.kind === "span-close" && e.name === "reconcile.repair",
    );
    expect(spanOpens).toHaveLength(1);
    expect(spanCloses).toHaveLength(1);
  });

  it("load() emits NO reconcile.repair span when the history is already valid", async () => {
    const { logger, events } = createCapturingLogger();
    const store = createConversationStore(storage, logger);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "hello" }] },
      { role: "assistant", chunks: [{ type: "text", text: "hi" }] },
    ];
    await store.append("conv_valid", messages);
    await store.load("conv_valid");

    const repairSpans = events.filter((e) => e.name === "reconcile.repair");
    expect(repairSpans).toHaveLength(0);
  });

  it("the reconcile.repair span carries conversationId + a repair count attribute", async () => {
    const { logger, events } = createCapturingLogger();
    const store = createConversationStore(storage, logger);
    const messages: ChatMessage[] = [
      {
        role: "assistant",
        chunks: [
          {
            type: "tool-call",
            toolCallId: "call_a",
            toolName: "toolA",
            input: {},
          },
          {
            type: "tool-call",
            toolCallId: "call_b",
            toolName: "toolB",
            input: {},
          },
        ],
      },
    ];
    await store.append("conv_multi", messages);
    await store.load("conv_multi");

    const spanOpen = events.find((e) => e.kind === "span-open" && e.name === "reconcile.repair");
    expect(spanOpen).toBeDefined();
    if (spanOpen === undefined) throw new Error("expected spanOpen");
    expect(spanOpen.conversationId).toBe("conv_multi");
    expect(spanOpen.attrs).toBeDefined();
    if (spanOpen.attrs === undefined) throw new Error("expected attrs");
    expect(spanOpen.attrs.repairedCount).toBe(2);
    expect(spanOpen.attrs.firstRepairedToolCallId).toBe("call_a");
  });

  it("createConversationStore works with the logger omitted (optional)", async () => {
    const store = createConversationStore(storage);
    const messages: ChatMessage[] = [
      { role: "user", chunks: [{ type: "text", text: "do it" }] },
      {
        role: "assistant",
        chunks: [
          {
            type: "tool-call",
            toolCallId: "call_nolog",
            toolName: "someTool",
            input: {},
          },
        ],
      },
    ];
    await store.append("conv_nolog", messages);
    const result = await store.load("conv_nolog");
    expect(result).toHaveLength(3);
    expect(result[2]?.role).toBe("tool");
    const chunk = result[2]?.chunks[0];
    if (chunk === undefined) throw new Error("expected chunk");
    expect(chunk.type).toBe("tool-result");
    if (chunk.type === "tool-result") {
      expect(chunk.toolCallId).toBe("call_nolog");
      expect(chunk.isError).toBe(true);
    }
  });
});

describe("ConversationStore cwd", () => {
  let storage: StorageNamespace;

  beforeEach(() => {
    storage = createMemoryStorage();
  });

  it("setCwd then getCwd returns the value", async () => {
    const store = createConversationStore(storage);
    await store.setCwd("conv1", "/home/user/project");
    const result = await store.getCwd("conv1");
    expect(result).toBe("/home/user/project");
  });

  it("getCwd returns null when never set", async () => {
    const store = createConversationStore(storage);
    const result = await store.getCwd("conv_unknown");
    expect(result).toBeNull();
  });

  it("setCwd is an upsert (second set overwrites)", async () => {
    const store = createConversationStore(storage);
    await store.setCwd("conv1", "/first/path");
    await store.setCwd("conv1", "/second/path");
    const result = await store.getCwd("conv1");
    expect(result).toBe("/second/path");
  });

  it("cwd persists across a fresh store instance on the same db file", async () => {
    const store1 = createConversationStore(storage);
    await store1.setCwd("conv1", "/persisted/path");

    const store2 = createConversationStore(storage);
    const result = await store2.getCwd("conv1");
    expect(result).toBe("/persisted/path");
  });

  it("cwd of one conversation does not leak into another", async () => {
    const store = createConversationStore(storage);
    await store.setCwd("convA", "/path/a");
    await store.setCwd("convB", "/path/b");
    expect(await store.getCwd("convA")).toBe("/path/a");
    expect(await store.getCwd("convB")).toBe("/path/b");
  });

  it("setCwd then clearCwd → getCwd returns null", async () => {
    const store = createConversationStore(storage);
    await store.setCwd("conv1", "/some/path");
    await store.clearCwd("conv1");
    expect(await store.getCwd("conv1")).toBeNull();
  });

  it("clearCwd on a conversation that never had a cwd set → no error, getCwd null", async () => {
    const store = createConversationStore(storage);
    await expect(store.clearCwd("never-seen")).resolves.toBeUndefined();
    expect(await store.getCwd("never-seen")).toBeNull();
  });

  it("clearCwd does not affect other conversations' cwds or other key spaces", async () => {
    const store = createConversationStore(storage);
    const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] };
    await store.append("conv1", [msg]);
    await store.setCwd("conv1", "/path/one");
    await store.setCwd("conv2", "/path/two");
    await store.setReasoningEffort("conv1", "high");
    const metrics: TurnMetrics = {
      turnId: "turn_iso",
      usage: { inputTokens: 100, outputTokens: 50 },
      steps: [],
    };
    await store.appendMetrics("conv1", metrics);

    // Clear conv1's cwd only.
    await store.clearCwd("conv1");

    // conv1 cwd is gone, but conv2 cwd survives.
    expect(await store.getCwd("conv1")).toBeNull();
    expect(await store.getCwd("conv2")).toBe("/path/two");

    // Other key spaces on conv1 are untouched.
    expect(await store.getReasoningEffort("conv1")).toBe("high");
    expect(await store.load("conv1")).toEqual([msg]);
    const chunks = await store.loadSince("conv1");
    expect(chunks).toHaveLength(1);
    expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" });
    const metricsResult = await store.loadMetrics("conv1");
    expect(metricsResult).toHaveLength(1);
    expect(metricsResult[0]).toEqual(metrics);
  });

  it("clearCwd is idempotent (clearing twice is a no-op)", async () => {
    const store = createConversationStore(storage);
    await store.setCwd("conv1", "/some/path");
    await store.clearCwd("conv1");
    // Second clear on an already-absent key — no error.
    await expect(store.clearCwd("conv1")).resolves.toBeUndefined();
    expect(await store.getCwd("conv1")).toBeNull();
  });

  it("setCwd after clearCwd re-persists the cwd (clear is a true delete, not a tombstone)", async () => {
    const store = createConversationStore(storage);
    await store.setCwd("conv1", "/first");
    await store.clearCwd("conv1");
    expect(await store.getCwd("conv1")).toBeNull();
    await store.setCwd("conv1", "/second");
    expect(await store.getCwd("conv1")).toBe("/second");
  });
});

describe("ConversationStore reasoning effort", () => {
  let storage: StorageNamespace;

  beforeEach(() => {
    storage = createMemoryStorage();
  });

  it("setReasoningEffort then getReasoningEffort returns the level", async () => {
    const store = createConversationStore(storage);
    await store.setReasoningEffort("conv1", "high");
    const result = await store.getReasoningEffort("conv1");
    expect(result).toBe("high");
  });

  it("getReasoningEffort returns null when never set", async () => {
    const store = createConversationStore(storage);
    const result = await store.getReasoningEffort("conv_unknown");
    expect(result).toBeNull();
  });

  it("reasoning effort of one conversation does not leak into another", async () => {
    const store = createConversationStore(storage);
    await store.setReasoningEffort("convA", "low");
    await store.setReasoningEffort("convB", "max");
    expect(await store.getReasoningEffort("convA")).toBe("low");
    expect(await store.getReasoningEffort("convB")).toBe("max");
  });

  it("setReasoningEffort is an upsert (second set overwrites)", async () => {
    const store = createConversationStore(storage);
    await store.setReasoningEffort("conv1", "medium");
    await store.setReasoningEffort("conv1", "xhigh");
    const result = await store.getReasoningEffort("conv1");
    expect(result).toBe("xhigh");
  });

  it("reasoning effort persists across a fresh store instance on the same storage", async () => {
    const store1 = createConversationStore(storage);
    await store1.setReasoningEffort("conv1", "max");

    const store2 = createConversationStore(storage);
    const result = await store2.getReasoningEffort("conv1");
    expect(result).toBe("max");
  });

  it("reasoning-effort keys do not collide with chunk/cwd/metrics key spaces", async () => {
    const store = createConversationStore(storage);
    const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] };
    await store.append("conv1", [msg]);
    await store.setCwd("conv1", "/some/path");
    await store.setReasoningEffort("conv1", "low");

    const metrics: TurnMetrics = {
      turnId: "turn_iso",
      usage: { inputTokens: 100, outputTokens: 50 },
      steps: [],
    };
    await store.appendMetrics("conv1", metrics);

    const messages = await store.load("conv1");
    expect(messages).toEqual([msg]);

    const chunks = await store.loadSince("conv1");
    expect(chunks).toHaveLength(1);
    expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" });

    expect(await store.getCwd("conv1")).toBe("/some/path");
    expect(await store.getReasoningEffort("conv1")).toBe("low");

    const metricsResult = await store.loadMetrics("conv1");
    expect(metricsResult).toHaveLength(1);
    expect(metricsResult[0]).toEqual(metrics);
  });
});

describe("ConversationStore model", () => {
  let storage: StorageNamespace;

  beforeEach(() => {
    storage = createMemoryStorage();
  });

  it("getModel returns null when never set", async () => {
    const store = createConversationStore(storage);
    expect(await store.getModel("conv_unknown")).toBeNull();
  });

  it("setModel then getModel returns the model name", async () => {
    const store = createConversationStore(storage);
    await store.setModel("conv1", "umans/umans-glm-5.2");
    expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2");
  });

  it("setModel is an upsert (second set overwrites with the latest)", async () => {
    const store = createConversationStore(storage);
    await store.setModel("conv1", "umans/umans-glm-5.2");
    await store.setModel("conv1", "openai/gpt-4o");
    expect(await store.getModel("conv1")).toBe("openai/gpt-4o");
  });

  it("setModel with an empty string clears the key (getModel returns null)", async () => {
    const store = createConversationStore(storage);
    await store.setModel("conv1", "umans/umans-glm-5.2");
    expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2");
    // Clear via the empty-string sentinel.
    await store.setModel("conv1", "");
    expect(await store.getModel("conv1")).toBeNull();
  });

  it("setModel('') on a never-set conversation is a no-op (idempotent clear)", async () => {
    const store = createConversationStore(storage);
    await expect(store.setModel("never-seen", "")).resolves.toBeUndefined();
    expect(await store.getModel("never-seen")).toBeNull();
  });

  it("setModel after a clear re-persists the model (clear is a true delete, not a tombstone)", async () => {
    const store = createConversationStore(storage);
    await store.setModel("conv1", "umans/umans-glm-5.2");
    await store.setModel("conv1", "");
    expect(await store.getModel("conv1")).toBeNull();
    await store.setModel("conv1", "openai/gpt-4o");
    expect(await store.getModel("conv1")).toBe("openai/gpt-4o");
  });

  it("model of one conversation does not leak into another", async () => {
    const store = createConversationStore(storage);
    await store.setModel("convA", "umans/umans-glm-5.2");
    await store.setModel("convB", "openai/gpt-4o");
    expect(await store.getModel("convA")).toBe("umans/umans-glm-5.2");
    expect(await store.getModel("convB")).toBe("openai/gpt-4o");
  });

  it("model persists across a fresh store instance on the same storage", async () => {
    const store1 = createConversationStore(storage);
    await store1.setModel("conv1", "umans/umans-glm-5.2");

    const store2 = createConversationStore(storage);
    expect(await store2.getModel("conv1")).toBe("umans/umans-glm-5.2");
  });

  it("model keys do not collide with chunk/cwd/metrics/reasoning-effort key spaces", async () => {
    const store = createConversationStore(storage);
    const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] };
    await store.append("conv1", [msg]);
    await store.setCwd("conv1", "/some/path");
    await store.setReasoningEffort("conv1", "low");
    await store.setModel("conv1", "umans/umans-glm-5.2");
    const metrics: TurnMetrics = {
      turnId: "turn_iso",
      usage: { inputTokens: 100, outputTokens: 50 },
      steps: [],
    };
    await store.appendMetrics("conv1", metrics);

    expect(await store.load("conv1")).toEqual([msg]);
    const chunks = await store.loadSince("conv1");
    expect(chunks).toHaveLength(1);
    expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" });
    expect(await store.getCwd("conv1")).toBe("/some/path");
    expect(await store.getReasoningEffort("conv1")).toBe("low");
    expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2");
    const metricsResult = await store.loadMetrics("conv1");
    expect(metricsResult).toHaveLength(1);
    expect(metricsResult[0]).toEqual(metrics);
  });

  it("forkHistory copies the model to the target", async () => {
    const store = createConversationStore(storage);
    await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]);
    await store.setModel("source", "umans/umans-glm-5.2");
    await store.forkHistory("source", "target");
    expect(await store.getModel("target")).toBe("umans/umans-glm-5.2");
  });

  it("forkHistory copies a cleared (unset) model as absent (target reads null)", async () => {
    const store = createConversationStore(storage);
    await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]);
    // No model set on source.
    await store.forkHistory("source", "target");
    expect(await store.getModel("target")).toBeNull();
  });

  it("replaceHistory preserves the model", async () => {
    const store = createConversationStore(storage);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]);
    await store.setModel("conv1", "umans/umans-glm-5.2");
    await store.replaceHistory("conv1", [
      { role: "user", chunks: [{ type: "text", text: "replaced" }] },
    ]);
    expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2");
    // History was replaced; the model survived the chunk-only sweep.
    expect(await store.load("conv1")).toEqual([
      { role: "user", chunks: [{ type: "text", text: "replaced" }] },
    ]);
  });
});

describe("ConversationStore conversation metadata + list + title", () => {
  let storage: StorageNamespace;

  beforeEach(() => {
    storage = createMemoryStorage();
  });

  it("listConversations: returns empty array when no conversations exist", async () => {
    const store = createConversationStore(storage);
    expect(await store.listConversations()).toEqual([]);
  });

  it("listConversations: returns conversations sorted by lastActivityAt desc", async () => {
    let clock = 1000;
    const store = createConversationStore(storage, undefined, () => clock);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]);
    clock = 2000;
    await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "second" }] }]);
    clock = 3000;
    await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "third" }] }]);
    // Bump conv1 to the most recent activity.
    clock = 4000;
    await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]);

    const list = await store.listConversations();
    expect(list.map((c) => c.id)).toEqual(["conv1", "conv3", "conv2"]);
  });

  it("listConversations: includes id + createdAt + lastActivityAt + title", async () => {
    const store = createConversationStore(storage, undefined, () => 12345);
    await store.append("convX", [{ role: "user", chunks: [{ type: "text", text: "my title" }] }]);
    const list = await store.listConversations();
    expect(list).toHaveLength(1);
    const first = list[0];
    if (first === undefined) throw new Error("expected list entry");
    expect(first).toEqual({
      id: "convX",
      createdAt: 12345,
      lastActivityAt: 12345,
      title: "my title",
      status: "idle",
      workspaceId: "default",
    });
  });

  it("getConversationMeta: returns null for unknown conversation", async () => {
    const store = createConversationStore(storage);
    expect(await store.getConversationMeta("unknown")).toBeNull();
  });

  it("getConversationMeta: returns metadata for known conversation", async () => {
    const store = createConversationStore(storage, undefined, () => 7777);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]);
    expect(await store.getConversationMeta("conv1")).toEqual({
      id: "conv1",
      createdAt: 7777,
      lastActivityAt: 7777,
      title: "hello",
      status: "idle",
      workspaceId: "default",
    });
  });

  it("getConversationMeta: returns null on a corrupt meta row", async () => {
    const store = createConversationStore(storage);
    // Write a meta row with the wrong shape directly to storage.
    await storage.set("conv:conv1:meta", "{not json");
    expect(await store.getConversationMeta("conv1")).toBeNull();
  });

  it("setConversationTitle: updates the title", async () => {
    const store = createConversationStore(storage, undefined, () => 1000);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]);
    await store.setConversationTitle("conv1", "custom title");
    const meta = await store.getConversationMeta("conv1");
    expect(meta?.title).toBe("custom title");
    // createdAt + lastActivityAt are preserved (setTitle does not bump them).
    expect(meta?.createdAt).toBe(1000);
    expect(meta?.lastActivityAt).toBe(1000);
  });

  it("setConversationTitle: creates meta if conversation is new", async () => {
    const store = createConversationStore(storage, undefined, () => 5000);
    await store.setConversationTitle("convNew", "preset title");
    expect(await store.getConversationMeta("convNew")).toEqual({
      id: "convNew",
      createdAt: 5000,
      lastActivityAt: 5000,
      title: "preset title",
      status: "idle",
      workspaceId: "default",
    });
    // And the new conversation is discoverable in the index.
    const list = await store.listConversations();
    expect(list.map((c) => c.id)).toEqual(["convNew"]);
  });

  it("append: auto-sets title from first user message", async () => {
    const store = createConversationStore(storage, undefined, () => 1000);
    await store.append("conv1", [
      { role: "system", chunks: [{ type: "text", text: "system prompt" }] },
      { role: "user", chunks: [{ type: "text", text: "hello world" }] },
      { role: "assistant", chunks: [{ type: "text", text: "hi" }] },
    ]);
    expect((await store.getConversationMeta("conv1"))?.title).toBe("hello world");
  });

  it("append: truncates long titles to 80 chars", async () => {
    const store = createConversationStore(storage, undefined, () => 1000);
    const longText = "x".repeat(100);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: longText }] }]);
    const meta = await store.getConversationMeta("conv1");
    expect(meta?.title).toBe(`${longText.slice(0, 80)}…`);
    expect(meta?.title.length).toBe(81);
  });

  it("append: sets createdAt on first write, preserves on subsequent", async () => {
    let clock = 1000;
    const store = createConversationStore(storage, undefined, () => clock);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]);
    clock = 5000;
    await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]);
    const meta = await store.getConversationMeta("conv1");
    expect(meta?.createdAt).toBe(1000);
    expect(meta?.lastActivityAt).toBe(5000);
  });

  it("append: updates lastActivityAt on every write", async () => {
    let clock = 1000;
    const store = createConversationStore(storage, undefined, () => clock);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]);
    expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(1000);
    clock = 2000;
    await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]);
    expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(2000);
    clock = 3000;
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]);
    expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(3000);
  });

  it('append: title "Untitled" updated when first user message arrives in later append', async () => {
    const store = createConversationStore(storage, undefined, () => 1000);
    // First append — assistant only, no user message yet → "Untitled".
    await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "hi" }] }]);
    expect((await store.getConversationMeta("conv1"))?.title).toBe("Untitled");
    // Second append — the first user message arrives → title is re-derived.
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "what now" }] }]);
    expect((await store.getConversationMeta("conv1"))?.title).toBe("what now");
  });

  it("append: a non-Untitled title is NOT overwritten by a later user message", async () => {
    const store = createConversationStore(storage, undefined, () => 1000);
    await store.append("conv1", [
      { role: "user", chunks: [{ type: "text", text: "first question" }] },
    ]);
    await store.append("conv1", [
      { role: "user", chunks: [{ type: "text", text: "second question" }] },
    ]);
    // The title stays as the first user message; later user messages do not clobber.
    expect((await store.getConversationMeta("conv1"))?.title).toBe("first question");
  });

  it("append: does not add the same conversation to the index twice", async () => {
    const store = createConversationStore(storage, undefined, () => 1000);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]);
    await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]);
    const list = await store.listConversations();
    expect(list).toHaveLength(1);
    expect(list[0]?.id).toBe("conv1");
  });

  it("listConversations: skips index entries whose meta row is missing", async () => {
    const store = createConversationStore(storage, undefined, () => 1000);
    await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]);
    // Manually corrupt the index by adding an id with no meta row.
    await storage.set("conv-index", JSON.stringify(["conv1", "ghost"]));
    const list = await store.listConversations();
    expect(list.map((c) => c.id)).toEqual(["conv1"]);
  });

  it("metadata persists across a fresh store instance on the same storage", async () => {
    const clock = 1000;
    const store1 = createConversationStore(storage, undefined, () => clock);
    await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "persisted" }] }]);

    const store2 = createConversationStore(storage);
    const meta = await store2.getConversationMeta("conv1");
    expect(meta).toEqual({
      id: "conv1",
      createdAt: 1000,
      lastActivityAt: 1000,
      title: "persisted",
      status: "idle",
      workspaceId: "default",
    });
    const list = await store2.listConversations();
    expect(list).toHaveLength(1);
    expect(list[0]?.id).toBe("conv1");
  });

  describe("ConversationStore conversation status", () => {
    it("new conversation defaults to idle", async () => {
      const store = createConversationStore(storage, undefined, () => 1000);
      await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]);
      expect(await store.getConversationStatus("conv1")).toBe("idle");
      expect((await store.getConversationMeta("conv1"))?.status).toBe("idle");
    });

    it("setConversationStatus updates status on existing conversation", async () => {
      const store = createConversationStore(storage, undefined, () => 1000);
      await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]);

      await store.setConversationStatus("conv1", "active");
      expect(await store.getConversationStatus("conv1")).toBe("active");

      await store.setConversationStatus("conv1", "idle");
      expect(await store.getConversationStatus("conv1")).toBe("idle");

      await store.setConversationStatus("conv1", "closed");
      expect(await store.getConversationStatus("conv1")).toBe("closed");
    });

    it("setConversationStatus creates minimal row for unknown conversation", async () => {
      const store = createConversationStore(storage, undefined, () => 2000);
      await store.setConversationStatus("convNew", "closed");
      expect(await store.getConversationStatus("convNew")).toBe("closed");
      const meta = await store.getConversationMeta("convNew");
      expect(meta?.status).toBe("closed");
      expect(meta?.title).toBe("Untitled");
    });

    it("setConversationStatus preserves other metadata", async () => {
      const store = createConversationStore(storage, undefined, () => 1000);
      await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]);
      await store.setConversationTitle("conv1", "custom title");

      await store.setConversationStatus("conv1", "active");

      const meta = await store.getConversationMeta("conv1");
      expect(meta?.title).toBe("custom title");
      expect(meta?.createdAt).toBe(1000);
      expect(meta?.status).toBe("active");
    });

    it("listConversations filters by status", async () => {
      const store = createConversationStore(storage, undefined, () => 1000);
      await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]);
      await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "b" }] }]);
      await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]);

      await store.setConversationStatus("conv1", "active");
      await store.setConversationStatus("conv2", "closed");

      const activeOnly = await store.listConversations({ status: ["active"] });
      expect(activeOnly.map((m) => m.id)).toEqual(["conv1"]);

      const idleOnly = await store.listConversations({ status: ["idle"] });
      expect(idleOnly.map((m) => m.id)).toEqual(["conv3"]);

      const activeIdle = await store.listConversations({ status: ["active", "idle"] });
      expect(activeIdle.map((m) => m.id)).toEqual(["conv1", "conv3"]);

      const all = await store.listConversations();
      expect(all.map((m) => m.id)).toEqual(["conv1", "conv2", "conv3"]);
    });

    it("status persists across a fresh store instance", async () => {
      const store1 = createConversationStore(storage, undefined, () => 1000);
      await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]);
      await store1.setConversationStatus("conv1", "active");

      const store2 = createConversationStore(storage);
      expect(await store2.getConversationStatus("conv1")).toBe("active");
    });

    it("old meta rows without status default to idle on read", async () => {
      // Simulate a pre-status meta row written by an older version.
      await storage.set(
        metaKey("conv1"),
        JSON.stringify({
          createdAt: 1000,
          lastActivityAt: 1000,
          title: "old",
        }),
      );
      await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(["conv1"]));

      const store = createConversationStore(storage);
      const meta = await store.getConversationMeta("conv1");
      expect(meta?.status).toBe("idle");
    });
  });
  it("extractTitle: returns first user text", () => {
    const messages: ChatMessage[] = [
      { role: "system", chunks: [{ type: "text", text: "sys" }] },
      { role: "assistant", chunks: [{ type: "text", text: "greeting" }] },
      { role: "user", chunks: [{ type: "text", text: "my question" }] },
      { role: "assistant", chunks: [{ type: "text", text: "answer" }] },
    ];
    expect(extractTitle(messages)).toBe("my question");
  });

  it('extractTitle: returns "Untitled" when no user message', () => {
    expect(extractTitle([])).toBe("Untitled");
    expect(
      extractTitle([
        { role: "system", chunks: [{ type: "text", text: "sys" }] },
        { role: "assistant", chunks: [{ type: "text", text: "hi" }] },
      ]),
    ).toBe("Untitled");
    // A user message with no text chunk also yields "Untitled".
    expect(
      extractTitle([
        {
          role: "user",
          chunks: [
            {
              type: "tool-result",
              toolCallId: "c",
              toolName: "t",
              content: "x",
              isError: false,
            },
          ],
        },
      ]),
    ).toBe("Untitled");
  });

  it("extractTitle: truncates to 80 chars", () => {
    const exactly80 = "a".repeat(80);
    const over80 = "a".repeat(81);
    const wayOver = "The quick brown fox jumps over the lazy dog. ".repeat(10);
    expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: exactly80 }] }])).toBe(
      exactly80,
    );
    expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: over80 }] }])).toBe(
      `${over80.slice(0, 80)}…`,
    );
    expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: wayOver }] }])).toBe(
      `${wayOver.slice(0, 80)}…`,
    );
  });

  it("extractTitle: uses the first text chunk of the first user message", () => {
    expect(
      extractTitle([
        {
          role: "user",
          chunks: [
            { type: "text", text: "first chunk" },
            { type: "text", text: "second chunk" },
          ],
        },
      ]),
    ).toBe("first chunk");
  });

  it("extractTitle: skips a user message with no text chunk, finds the next", () => {
    expect(
      extractTitle([
        {
          role: "user",
          chunks: [
            {
              type: "tool-result",
              toolCallId: "c",
              toolName: "t",
              content: "x",
              isError: false,
            },
          ],
        },
        { role: "user", chunks: [{ type: "text", text: "real question" }] },
      ]),
    ).toBe("real question");
  });

  it("extractTitle: does not mutate the input", () => {
    const messages: ChatMessage[] = [{ role: "user", chunks: [{ type: "text", text: "hello" }] }];
    const snapshot = JSON.stringify(messages);
    extractTitle(messages);
    expect(JSON.stringify(messages)).toBe(snapshot);
  });
});
