AkurAI Build
Menu

popagent

public

Latest change 6edc7e928c57dab23c0973ed3f7ee70b3ebb72d5 - Initial popagent baseline by AkurAI Build

import { afterAll, describe, expect, test } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import {
  MAX_AGENT_INSTRUCTIONS_CHARACTERS,
  type AgentListResponse,
  type AgentRuntimeSettings,
  type BrowserSettings,
  type ModelCatalogResponse,
  type SessionListResponse,
  type WorkspaceListResponse,
} from "./api-types";
import { WORKSPACE_ROOT } from "./agent-workspaces";
import { startServer } from "./server";
import { sessions } from "./server";
import { HookRuntime, type HookEvent } from "./hooks";
import { hookAudit } from "./hook-audit";
import { longTermMemory } from "./long-term-memory";

const testSessions = sessions;
const server = startServer(0, testSessions);
const base = `http://127.0.0.1:${server.port}`;

const hookEvents: HookEvent[] = [];
const hookRuntime = new HookRuntime({
  config: {
    version: 1,
    hooks: {
      SessionStart: [{ id: "session", type: "http", url: "https://hooks.example/session" }],
      UserPromptSubmit: [{ id: "prompt", type: "http", url: "https://hooks.example/prompt" }],
    },
  },
  transport: async (_handler, event) => {
    hookEvents.push(event);
    return event.eventName === "UserPromptSubmit"
      ? { schemaVersion: 1, outcome: "deny", reason: "test policy denied prompt" }
      : { schemaVersion: 1, outcome: "pass" };
  },
});
const hookedServer = startServer(0, testSessions, hookRuntime);
const hookedBase = `http://127.0.0.1:${hookedServer.port}`;

afterAll(() => {
  server.stop(true);
  hookedServer.stop(true);
});

function joinDeltas(sse: string): string {
  return [...sse.matchAll(/"type":"text-delta".*?"delta":"((?:[^"\\]|\\.)*)"/g)]
    .map((m) => JSON.parse(`"${m[1]}"`))
    .join("");
}

function chatBody(model?: string) {
  return JSON.stringify({
    ...(model ? { model } : {}),
    messages: [
      {
        id: "1",
        role: "user",
        parts: [{ type: "text", text: "Reply with exactly the word: pong" }],
      },
    ],
  });
}

describe("browser application routes", () => {
  test("serves the application shell for settings deep links", async () => {
    const response = await fetch(`${base}/settings/browser`);
    expect(response.status).toBe(200);
    expect(response.headers.get("content-type")).toContain("text/html");
    expect(await response.text()).toContain("<title>popagent</title>");
  });

  test("keeps unmatched API routes out of the application shell", async () => {
    const response = await fetch(`${base}/api/not-a-route`);
    expect(response.status).toBe(404);
    expect(await response.json()).toEqual({ error: "Not found" });
  });
});

describe("GET /api/models", () => {
  test("lists selectable models", async () => {
    const res = await fetch(`${base}/api/models`);
    expect(res.status).toBe(200);
    const { models, defaultModel, contextWindows } = (await res.json()) as ModelCatalogResponse;
    expect(defaultModel.length).toBeGreaterThan(0);
    expect(models).toContain(defaultModel);
    expect(contextWindows[defaultModel]).toBeGreaterThan(0);
  });
});
describe("/api/agents", () => {
  test("lists agents and persists complete identity edits", async () => {
    const listResponse = await fetch(`${base}/api/agents`);
    expect(listResponse.status).toBe(200);
    const listed = (await listResponse.json()) as AgentListResponse;
    const original = listed.agents.find((item) => item.id === "popagent");
    if (!original) throw new Error("popagent settings missing");

    const input = {
      name: `API agent ${crypto.randomUUID().slice(0, 8)}`,
      description: "API-managed supervisor",
      instructions: `API test ${crypto.randomUUID()}`,
      workspaceAccess: original.workspaceAccess,
      browserAccess: original.browserAccess,
      delegationEnabled: original.delegationEnabled,
      tools: original.tools,
    };
    try {
      const updateResponse = await fetch(`${base}/api/agents/popagent`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(input),
      });
      expect(updateResponse.status).toBe(200);
      expect(await updateResponse.json()).toMatchObject(input);
    } finally {
      await fetch(`${base}/api/agents/popagent`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          name: original.name,
          description: original.description,
          workspaceAccess: original.workspaceAccess,
          browserAccess: original.browserAccess,
          delegationEnabled: original.delegationEnabled,
          tools: original.tools,
          instructions: original.instructions,
        }),
      });
    }
  });

  test("rejects invalid complete identity payloads", async () => {
    const oversized = await fetch(`${base}/api/agents/popagent`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        name: "popagent",
        description: "Supervisor",
        instructions: "x".repeat(MAX_AGENT_INSTRUCTIONS_CHARACTERS + 1),
        workspaceAccess: "read-write",
        browserAccess: "interactive",
        delegationEnabled: false,
        tools: ["webSearch"],
      }),
    });
    expect(oversized.status).toBe(400);
    expect(await oversized.json()).toEqual(expect.objectContaining({
      error: "Invalid agent settings",
      issues: expect.any(Array),
    }));

    const incomplete = await fetch(`${base}/api/agents/popagent`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ instructions: "instructions only" }),
    });
    expect(incomplete.status).toBe(400);
  });
});
describe("/api/settings/browser", () => {
  test("persists browser enablement", async () => {
    const getResponse = await fetch(`${base}/api/settings/browser`);
    expect(getResponse.status).toBe(200);
    const original = (await getResponse.json()) as BrowserSettings;
    const { updatedAt: _updatedAt, ...originalInput } = original;
    const updateResponse = await fetch(`${base}/api/settings/browser`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ ...originalInput, enabled: !original.enabled }),
    });
    expect(updateResponse.status).toBe(200);
    expect(((await updateResponse.json()) as { enabled: boolean }).enabled).toBe(!original.enabled);
    await fetch(`${base}/api/settings/browser`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(originalInput),
    });
  });
});

describe("/api/settings/memory", () => {
  test("validates and persists conversation compaction settings", async () => {
    const getResponse = await fetch(`${base}/api/settings/memory`);
    expect(getResponse.status).toBe(200);
    const original = await getResponse.json() as Record<string, unknown>;
    const input = { autoCompact: false, observationTokens: 24_000, reflectionTokens: 48_000, recentMessagePercent: 25, bufferOnIdle: false };
    try {
      const updateResponse = await fetch(`${base}/api/settings/memory`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(input),
      });
      expect(updateResponse.status).toBe(200);
      expect(await updateResponse.json()).toMatchObject(input);
      expect((await fetch(`${base}/api/settings/memory`)).status).toBe(200);
      expect((await fetch(`${base}/api/settings/memory`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ ...input, observationTokens: 100 }),
      })).status).toBe(400);
    } finally {
      await fetch(`${base}/api/settings/memory`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(original),
      });
    }
  });
});
describe("/api/settings/agent-runtime", () => {
  test("validates and persists execution settings", async () => {
    const getResponse = await fetch(`${base}/api/settings/agent-runtime`);
    expect(getResponse.status).toBe(200);
    const original = await getResponse.json() as AgentRuntimeSettings;
    const { updatedAt, ...originalInput } = original;
    void updatedAt;
    const input = {
      ...originalInput,
      supervisorMaxSteps: original.supervisorMaxSteps === 23 ? 24 : 23,
      toolConcurrency: original.toolConcurrency === 2 ? 3 : 2,
    };
    try {
      const updateResponse = await fetch(`${base}/api/settings/agent-runtime`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(input),
      });
      expect(updateResponse.status).toBe(200);
      expect(await updateResponse.json()).toMatchObject(input);
      expect(await (await fetch(`${base}/api/settings/agent-runtime`)).json()).toMatchObject(input);

      const invalidResponse = await fetch(`${base}/api/settings/agent-runtime`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ ...input, supervisorMaxSteps: 0 }),
      });
      expect(invalidResponse.status).toBe(400);
    } finally {
      await fetch(`${base}/api/settings/agent-runtime`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(originalInput),
      });
    }
  });
});
describe("/api/agents/:id/skills", () => {
  test("creates, updates, lists, and deletes a skill", async () => {
    const name = `api-skill-${crypto.randomUUID().slice(0, 8)}`;
    const input = { name, description: "API skill test", instructions: "Use API_SKILL_READY.", references: {}, enabled: true, userInvocable: true };
    const createdResponse = await fetch(`${base}/api/agents/popagent/skills`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(input),
    });
    expect(createdResponse.status).toBe(201);
    const created = (await createdResponse.json()) as { id: string };
    const listResponse = await fetch(`${base}/api/agents/popagent/skills`);
    expect(((await listResponse.json()) as { skills: Array<{ name: string }> }).skills.map((skill) => skill.name)).toContain(name);
    const updateResponse = await fetch(`${base}/api/agents/popagent/skills/${created.id}`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ ...input, enabled: false }),
    });
    expect(((await updateResponse.json()) as { enabled: boolean }).enabled).toBe(false);
    expect((await fetch(`${base}/api/agents/popagent/skills/${created.id}`, { method: "DELETE" })).status).toBe(204);
  });
});
describe("/api/workspaces", () => {
  test("creates a repository workspace and scopes sessions to it", async () => {
    const repositoryPath = `api-workspace-${crypto.randomUUID()}`;
    await mkdir(resolve(WORKSPACE_ROOT, repositoryPath), { recursive: true });
    let workspaceId: string | undefined;
    let sessionId: string | undefined;
    try {
      const createdResponse = await fetch(`${base}/api/workspaces`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ name: "API workspace", repositoryPath }),
      });
      expect(createdResponse.status).toBe(201);
      workspaceId = ((await createdResponse.json()) as { id: string }).id;

      const listedResponse = await fetch(`${base}/api/workspaces`);
      expect(listedResponse.status).toBe(200);
      expect(((await listedResponse.json()) as WorkspaceListResponse).workspaces).toContainEqual(
        expect.objectContaining({ id: workspaceId, repositoryPath }),
      );

      const sessionResponse = await fetch(`${base}/api/sessions`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ workspaceId }),
      });
      expect(sessionResponse.status).toBe(201);
      sessionId = ((await sessionResponse.json()) as { id: string; workspaceId: string }).id;

      const sessionsResponse = await fetch(`${base}/api/sessions?workspaceId=${workspaceId}`);
      expect(sessionsResponse.status).toBe(200);
      expect(((await sessionsResponse.json()) as SessionListResponse).sessions).toContainEqual(
        expect.objectContaining({ id: sessionId, workspaceId }),
      );

      expect((await fetch(`${base}/api/workspaces/${workspaceId}`, { method: "DELETE" })).status).toBe(409);
      expect((await fetch(`${base}/api/sessions/${sessionId}`, { method: "DELETE" })).status).toBe(204);
      sessionId = undefined;
      expect((await fetch(`${base}/api/workspaces/${workspaceId}`, { method: "DELETE" })).status).toBe(204);
      workspaceId = undefined;
    } finally {
      if (sessionId) await fetch(`${base}/api/sessions/${sessionId}`, { method: "DELETE" });
      if (workspaceId) await fetch(`${base}/api/workspaces/${workspaceId}`, { method: "DELETE" });
      await rm(resolve(WORKSPACE_ROOT, repositoryPath), { recursive: true, force: true });
    }
  });
});

describe("/api/sessions", () => {
  test("persists a session and lists its generated title", async () => {
    const createdResponse = await fetch(`${base}/api/sessions`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ model: "cx/gpt-5.6-sol" }),
    });
    expect(createdResponse.status).toBe(201);
    const created = (await createdResponse.json()) as { id: string; revision: number };

    const messages = [
      {
        id: "user-1",
        role: "user",
        parts: [{ type: "text", text: "Remember this conversation" }],
      },
    ];
    const savedResponse = await fetch(`${base}/api/sessions/${created.id}`, {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ model: "cx/gpt-5.6-sol", messages, revision: created.revision }),
    });
    expect(savedResponse.status).toBe(200);

    const staleResponse = await fetch(`${base}/api/sessions/${created.id}`, {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ model: "cx/gpt-5.6-sol", messages, revision: created.revision }),
    });
    expect(staleResponse.status).toBe(409);
    const conflict = (await staleResponse.json()) as { error: string; session: { revision: number } };
    expect(conflict.error).toBe("revision conflict");
    expect(conflict.session.revision).toBeGreaterThan(created.revision);

    const invalidResponse = await fetch(`${base}/api/sessions/${created.id}`, {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: "not json",
    });
    expect(invalidResponse.status).toBe(400);

    const listResponse = await fetch(`${base}/api/sessions`);
    const { sessions: listed } = (await listResponse.json()) as SessionListResponse;
    expect(listed[0]).toEqual(
      expect.objectContaining({ id: created.id, title: "Remember this conversation" }),
    );

    const loadedResponse = await fetch(`${base}/api/sessions/${created.id}`);
    expect(await loadedResponse.json()).toEqual(expect.objectContaining({ messages }));
  });
});

describe("session lifecycle", () => {
  test("renames, archives, restores, and permanently deletes a chat", async () => {
    const createdResponse = await fetch(`${base}/api/sessions`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ model: "cx/gpt-5.6-sol" }),
    });
    const created = (await createdResponse.json()) as { id: string };
    const renamedResponse = await fetch(`${base}/api/sessions/${created.id}`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ title: "Pinned title" }),
    });
    expect(renamedResponse.status).toBe(200);
    expect(await renamedResponse.json()).toEqual(expect.objectContaining({ title: "Pinned title" }));
    const archivedResponse = await fetch(`${base}/api/sessions/${created.id}`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ archived: true }),
    });
    expect(archivedResponse.status).toBe(200);
    expect(await archivedResponse.json()).toEqual(expect.objectContaining({ archivedAt: expect.any(String) }));
    const archivedList = await fetch(`${base}/api/sessions?archived=true`);
    expect((await archivedList.json()) as unknown).toEqual({
      sessions: expect.arrayContaining([expect.objectContaining({ id: created.id })]),
    });
    const restoredResponse = await fetch(`${base}/api/sessions/${created.id}`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ archived: false }),
    });
    const timestamp = new Date().toISOString();
    await hookAudit.record({
      id: crypto.randomUUID(),
      eventId: crypto.randomUUID(),
      sessionId: created.id,
      eventName: "SessionEnd",
      handlerId: "session-cleanup-test",
      startedAt: timestamp,
      completedAt: timestamp,
      durationMs: 0,
      status: "passed",
    });
    expect(await hookAudit.listForSession(created.id)).toContainEqual(
      expect.objectContaining({ handlerId: "session-cleanup-test" }),
    );
    expect(restoredResponse.status).toBe(200);
    const deletedResponse = await fetch(`${base}/api/sessions/${created.id}`, { method: "DELETE" });
    expect(deletedResponse.status).toBe(204);
    expect((await fetch(`${base}/api/sessions/${created.id}`)).status).toBe(404);
    expect(await hookAudit.listForSession(created.id)).toEqual([]);
  });
});

describe("memory management", () => {
  test("creates, filters, edits, and deletes a durable fact", async () => {
    const marker = `gui-memory-${crypto.randomUUID()}`;
    const createdResponse = await fetch(`${base}/api/memories`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ key: marker, content: `Original ${marker}`, importance: 0.7 }),
    });
    expect(createdResponse.status).toBe(201);
    const created = (await createdResponse.json()) as { id: string };
    const listResponse = await fetch(`${base}/api/memories?kind=fact&query=${marker}`);
    expect(await listResponse.json()).toEqual({
      memories: [expect.objectContaining({ id: created.id, key: marker, kind: "fact" })],
    });
    const updatedResponse = await fetch(`${base}/api/memories/${created.id}`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ key: marker, content: `Updated ${marker}`, importance: 0.95 }),
    });
    expect(updatedResponse.status).toBe(200);
    expect(await updatedResponse.json()).toEqual(expect.objectContaining({
      content: `Updated ${marker}`,
      importance: 0.95,
    }));
    expect((await fetch(`${base}/api/memories/${created.id}`, { method: "DELETE" })).status).toBe(204);
    expect((await fetch(`${base}/api/memories/${created.id}`, { method: "DELETE" })).status).toBe(404);
  });

  test("rejects invalid and oversized managed fact payloads", async () => {
    const invalidCreates = [
      { content: "x".repeat(2_001), importance: 0.8 },
      { content: "probe", key: 42, importance: 0.8 },
      { content: "probe", key: "valid", importance: "not-a-number" },
      { content: "probe", importance: 2 },
      { content: "probe", importance: 0.8, unexpected: true },
    ];
    for (const body of invalidCreates) {
      const response = await fetch(`${base}/api/memories`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(body),
      });
      expect(response.status).toBe(400);
      expect(await response.json()).toEqual(expect.objectContaining({
        error: "Invalid memory",
        issues: expect.any(Array),
      }));
    }

    const invalidUpdate = await fetch(`${base}/api/memories/missing`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ content: "probe", key: 42, importance: "not-a-number" }),
    });
    expect(invalidUpdate.status).toBe(400);
    expect(await invalidUpdate.json()).toEqual(expect.objectContaining({
      error: "Invalid memory",
      issues: expect.any(Array),
    }));
  });

  test("lists hook activity for a session", async () => {
    const response = await fetch(`${base}/api/sessions/missing/hooks`);
    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({ runs: [] });
  });
});

describe("hook lifecycle", () => {
  test("fires SessionStart after creating a persistent session", async () => {
    const response = await fetch(`${hookedBase}/api/sessions`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ model: "cx/gpt-5.6-sol" }),
    });
    const session = (await response.json()) as { id: string };

    expect(response.status).toBe(201);
    expect(hookEvents).toContainEqual(expect.objectContaining({
      eventName: "SessionStart",
      sessionId: session.id,
      detail: expect.objectContaining({ source: "create" }),
    }));
  });

  test("returns 403 without calling the model when a prompt hook denies", async () => {
    const response = await fetch(`${hookedBase}/api/chat`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: chatBody(),
    });

    expect(response.status).toBe(403);
    expect(await response.json()).toEqual({ error: "test policy denied prompt" });
    expect(hookEvents).toContainEqual(expect.objectContaining({ eventName: "UserPromptSubmit" }));
  });
});

describe("cross-session memory", () => {
  test("recalls a durable fact in a different chat session", async () => {
    const key = `favorite-code-word-${crypto.randomUUID().slice(0, 8)}`;
    const codeWord = `heliotrope-${crypto.randomUUID().slice(0, 8)}`;
    await longTermMemory.rememberFact({
      resourceId: "popagent-user",
      sessionId: "memory-source-session",
      key,
      content: `The user's ${key} is ${codeWord}.`,
      importance: 1,
    });

    const response = await fetch(`${base}/api/chat`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        id: crypto.randomUUID(),
        messages: [{
          id: "memory-query",
          role: "user",
          parts: [{ type: "text", text: `What is my ${key}? Reply with only the value.` }],
        }],
      }),
    });

    expect(response.status).toBe(200);
    expect(joinDeltas(await response.text())).toContain(codeWord);
  }, 60000);
});


describe("POST /api/chat", () => {
  test("streams a UI message response", async () => {
    const res = await fetch(`${base}/api/chat`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: chatBody(),
    });
    expect(res.status).toBe(200);
    expect(joinDeltas(await res.text()).toLowerCase()).toContain("pong");
  }, 60000);

  test("honors a model override in the body", async () => {
    const res = await fetch(`${base}/api/chat`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: chatBody("auto/fast"),
    });
    expect(res.status).toBe(200);
    expect(joinDeltas(await res.text()).toLowerCase()).toContain("pong");
  }, 60000);
});