branch:
assistant-session.test.ts
11960 bytesRaw
import { env } from "cloudflare:workers";
import { describe, expect, it } from "vitest";
import { getAgentByName } from "agents";
import type { UIMessage } from "ai";
import {
  truncateHead,
  truncateTail,
  truncateLines,
  truncateMiddle,
  truncateToolOutput
} from "../session/index";

async function freshAgent(name: string) {
  return getAgentByName(env.TestAssistantSessionAgent, name);
}

function userMsg(id: string, text: string): UIMessage {
  return { id, role: "user", parts: [{ type: "text", text }] };
}

function assistantMsg(id: string, text: string): UIMessage {
  return { id, role: "assistant", parts: [{ type: "text", text }] };
}

// ── Truncation utilities (pure functions) ─────────────────────────

describe("truncation — truncateHead", () => {
  it("returns text unchanged if under limit", () => {
    expect(truncateHead("hello", 100)).toBe("hello");
  });

  it("keeps the end of text", () => {
    const result = truncateHead("abcdefghij", 8);
    expect(result.endsWith("ij")).toBe(true);
    expect(result.length).toBeLessThanOrEqual(8);
  });
});

describe("truncation — truncateTail", () => {
  it("returns text unchanged if under limit", () => {
    expect(truncateTail("hello", 100)).toBe("hello");
  });

  it("keeps the start of text", () => {
    const result = truncateTail("abcdefghij", 8);
    expect(result.startsWith("ab")).toBe(true);
    expect(result.length).toBeLessThanOrEqual(8);
  });
});

describe("truncation — truncateLines", () => {
  it("returns text unchanged if under limit", () => {
    expect(truncateLines("a\nb\nc", 10)).toBe("a\nb\nc");
  });

  it("truncates to max lines", () => {
    const input = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n");
    const result = truncateLines(input, 5);
    expect(result).toContain("line0");
    expect(result).toContain("line4");
    expect(result).not.toContain("line5");
    expect(result).toContain("15 more lines truncated");
  });
});

describe("truncation — truncateMiddle", () => {
  it("returns text unchanged if under limit", () => {
    expect(truncateMiddle("hello", 100)).toBe("hello");
  });

  it("keeps start and end", () => {
    const input = "START" + "x".repeat(100) + "END";
    const result = truncateMiddle(input, 50);
    expect(result).toContain("START");
    expect(result).toContain("END");
    expect(result).toContain("truncated");
    expect(result.length).toBeLessThanOrEqual(50);
  });
});

describe("truncation — truncateToolOutput", () => {
  it("applies line then char truncation", () => {
    const lines = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join(
      "\n"
    );
    const result = truncateToolOutput(lines, {
      maxLines: 10,
      maxChars: 100,
      strategy: "tail"
    });
    expect(result.length).toBeLessThanOrEqual(100);
  });
});

// ── Session lifecycle ─────────────────────────────────────────────

describe("session — lifecycle", () => {
  it("creates and retrieves a session", async () => {
    const agent = await freshAgent("lifecycle-create");
    const session = await agent.createSession("test-chat");
    expect(session.name).toBe("test-chat");
    expect(session.id).toBeTruthy();

    const retrieved = await agent.getSession(session.id);
    expect(retrieved).not.toBeNull();
    expect(retrieved!.name).toBe("test-chat");
  });

  it("lists multiple sessions", async () => {
    const agent = await freshAgent("lifecycle-list");
    await agent.createSession("first");
    await agent.createSession("second");
    const sessions = await agent.listSessions();
    expect(sessions.length).toBe(2);
    const names = sessions.map((s: { name: string }) => s.name);
    expect(names).toContain("first");
    expect(names).toContain("second");
  });

  it("renames a session", async () => {
    const agent = await freshAgent("lifecycle-rename");
    const session = await agent.createSession("old-name");
    await agent.renameSession(session.id, "new-name");
    const retrieved = await agent.getSession(session.id);
    expect(retrieved!.name).toBe("new-name");
  });

  it("deletes a session and its messages", async () => {
    const agent = await freshAgent("lifecycle-delete");
    const session = await agent.createSession("to-delete");
    await agent.appendMessage(session.id, userMsg("m1", "hello"));
    await agent.deleteSession(session.id);

    const retrieved = await agent.getSession(session.id);
    expect(retrieved).toBeNull();

    const count = await agent.getMessageCount(session.id);
    expect(count).toBe(0);
  });
});

// ── Messages ──────────────────────────────────────────────────────

describe("session — messages", () => {
  it("appends messages and retrieves history", async () => {
    const agent = await freshAgent("messages-basic");
    const session = await agent.createSession("chat");

    await agent.appendMessage(session.id, userMsg("u1", "hello"));
    await agent.appendMessage(session.id, assistantMsg("a1", "hi there"));
    await agent.appendMessage(session.id, userMsg("u2", "how are you?"));

    const history = (await agent.getHistory(
      session.id
    )) as unknown as UIMessage[];
    expect(history.length).toBe(3);
    expect(history[0].role).toBe("user");
    expect(history[1].role).toBe("assistant");
    expect(history[2].role).toBe("user");
  });

  it("appends all messages in sequence", async () => {
    const agent = await freshAgent("messages-appendall");
    const session = await agent.createSession("chat");

    await agent.appendAllMessages(session.id, [
      userMsg("u1", "hello"),
      assistantMsg("a1", "hi"),
      userMsg("u2", "bye")
    ]);

    const history = (await agent.getHistory(
      session.id
    )) as unknown as UIMessage[];
    expect(history.length).toBe(3);
  });

  it("counts messages", async () => {
    const agent = await freshAgent("messages-count");
    const session = await agent.createSession("chat");

    expect(await agent.getMessageCount(session.id)).toBe(0);
    await agent.appendMessage(session.id, userMsg("u1", "hello"));
    expect(await agent.getMessageCount(session.id)).toBe(1);
    await agent.appendMessage(session.id, assistantMsg("a1", "hi"));
    expect(await agent.getMessageCount(session.id)).toBe(2);
  });
});

// ── Branching ─────────────────────────────────────────────────────

describe("session — branching", () => {
  it("creates branches by appending to different parents", async () => {
    const agent = await freshAgent("branch-basic");
    const session = await agent.createSession("chat");

    const rootId = await agent.appendMessage(
      session.id,
      userMsg("u1", "What is 2+2?")
    );
    const branchAId = await agent.appendMessage(
      session.id,
      assistantMsg("a1", "It's 4"),
      rootId
    );
    const branchBId = await agent.appendMessage(
      session.id,
      assistantMsg("a2", "The answer is four"),
      rootId
    );

    // Branch A path
    const historyA = (await agent.getHistory(
      session.id,
      branchAId
    )) as unknown as UIMessage[];
    expect(historyA.length).toBe(2);
    expect(historyA[1].parts[0]).toMatchObject({
      type: "text",
      text: "It's 4"
    });

    // Branch B path
    const historyB = (await agent.getHistory(
      session.id,
      branchBId
    )) as unknown as UIMessage[];
    expect(historyB.length).toBe(2);
    expect(historyB[1].parts[0]).toMatchObject({
      type: "text",
      text: "The answer is four"
    });
  });

  it("lists branches from a message", async () => {
    const agent = await freshAgent("branch-list");
    const session = await agent.createSession("chat");

    const rootId = await agent.appendMessage(
      session.id,
      userMsg("u1", "question")
    );
    await agent.appendMessage(
      session.id,
      assistantMsg("a1", "answer 1"),
      rootId
    );
    await agent.appendMessage(
      session.id,
      assistantMsg("a2", "answer 2"),
      rootId
    );

    const branches = (await agent.getBranches(
      rootId
    )) as unknown as UIMessage[];
    expect(branches.length).toBe(2);
  });

  it("forks a session at a specific message", async () => {
    const agent = await freshAgent("branch-fork");
    const session = await agent.createSession("original");

    await agent.appendAllMessages(session.id, [
      userMsg("u1", "hello"),
      assistantMsg("a1", "hi"),
      userMsg("u2", "how are you?"),
      assistantMsg("a2", "I'm fine")
    ]);

    // Get the history to find the message ID to fork at
    const history = (await agent.getHistory(
      session.id
    )) as unknown as UIMessage[];
    // Fork at the second message (a1 "hi")
    const forkAtId = history[1].id;

    const forked = await agent.forkSession(session.id, forkAtId, "forked-chat");
    expect(forked.name).toBe("forked-chat");

    // Forked session should have 2 messages (up to fork point)
    const forkedHistory = (await agent.getHistory(
      forked.id
    )) as unknown as UIMessage[];
    expect(forkedHistory.length).toBe(2);

    // Original should still have all 4
    const originalHistory = (await agent.getHistory(
      session.id
    )) as unknown as UIMessage[];
    expect(originalHistory.length).toBe(4);
  });
});

// ── Compaction ────────────────────────────────────────────────────

describe("session — compaction", () => {
  it("replaces compacted messages with summary in history", async () => {
    const agent = await freshAgent("compaction-basic");
    const session = await agent.createSession("chat");

    const ids = [];
    for (let i = 0; i < 5; i++) {
      const id = await agent.appendMessage(
        session.id,
        i % 2 === 0
          ? userMsg(`u${i}`, `user message ${i}`)
          : assistantMsg(`a${i}`, `assistant message ${i}`)
      );
      ids.push(id);
    }

    // Add a compaction covering the first 3 messages
    await agent.addCompaction(
      session.id,
      "The user and assistant discussed messages 0-2",
      ids[0],
      ids[2]
    );

    const history = (await agent.getHistory(
      session.id
    )) as unknown as UIMessage[];
    // Should be: 1 compaction summary + 2 remaining messages = 3
    expect(history.length).toBe(3);
    // First should be the compaction summary (system role)
    expect(history[0].role).toBe("system");
    expect(history[0].parts[0]).toMatchObject({
      type: "text",
      text: expect.stringContaining("Previous conversation summary")
    });
    expect(history[0].parts[0]).toMatchObject({
      type: "text",
      text: expect.stringContaining(
        "The user and assistant discussed messages 0-2"
      )
    });
  });

  it("reports needsCompaction when history exceeds threshold", async () => {
    const agent = await freshAgent("compaction-needs");
    const session = await agent.createSession("chat");

    // Default threshold is 100 messages
    expect(await agent.needsCompaction(session.id)).toBe(false);
  });

  it("lists compaction records", async () => {
    const agent = await freshAgent("compaction-list");
    const session = await agent.createSession("chat");

    const id1 = await agent.appendMessage(session.id, userMsg("u1", "hello"));
    const id2 = await agent.appendMessage(session.id, assistantMsg("a1", "hi"));

    await agent.addCompaction(session.id, "summary", id1, id2);
    const compactions = await agent.getCompactions(session.id);
    expect(compactions.length).toBe(1);
    expect(compactions[0].summary).toBe("summary");
  });
});