From a89f0ae0b22b9afd2aed0457ed251dcc34df47d7 Mon Sep 17 00:00:00 2001 From: Ashutosh Kasaudhan Date: Tue, 28 Jul 2026 16:53:24 +0000 Subject: [PATCH] repo: add bun test setup and unit tests for uncovered modules Covers the DAG planner, skill store, context reducers, backend auth helpers/middleware, and frontend session/api/sse/describeEvent. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/package.json | 3 +- apps/backend/src/modules/middleware.test.ts | 154 ++++++++++ apps/backend/src/modules/user.helpers.test.ts | 127 ++++++++ apps/frontend/package.json | 2 + .../src/features/build/describeEvent.test.ts | 62 ++++ apps/frontend/src/lib/api.test.ts | 168 ++++++++++ apps/frontend/src/lib/session.test.ts | 79 +++++ apps/frontend/src/lib/sse.test.ts | 192 ++++++++++++ apps/frontend/tsconfig.json | 2 +- bun.lock | 1 + package.json | 3 +- packages/agents/agent/services/dag.test.ts | 130 ++++++++ packages/agents/agent/skills/skills.test.ts | 107 +++++++ packages/agents/agent/utils/context.test.ts | 288 ++++++++++++++++++ .../agent/utils/{sb.test.ts => sb.manual.ts} | 0 packages/agents/package.json | 3 +- turbo.json | 4 + 17 files changed, 1321 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/modules/middleware.test.ts create mode 100644 apps/backend/src/modules/user.helpers.test.ts create mode 100644 apps/frontend/src/features/build/describeEvent.test.ts create mode 100644 apps/frontend/src/lib/api.test.ts create mode 100644 apps/frontend/src/lib/session.test.ts create mode 100644 apps/frontend/src/lib/sse.test.ts create mode 100644 packages/agents/agent/services/dag.test.ts create mode 100644 packages/agents/agent/skills/skills.test.ts create mode 100644 packages/agents/agent/utils/context.test.ts rename packages/agents/agent/utils/{sb.test.ts => sb.manual.ts} (100%) diff --git a/apps/backend/package.json b/apps/backend/package.json index 27221a6..20d7172 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -5,7 +5,8 @@ "private": true, "scripts": { "dev": "bun --hot src/index.ts", - "worker": "bun --hot src/modules/worker.ts" + "worker": "bun --hot src/modules/worker.ts", + "test": "bun test src" }, "devDependencies": { "@types/bun": "latest", diff --git a/apps/backend/src/modules/middleware.test.ts b/apps/backend/src/modules/middleware.test.ts new file mode 100644 index 0000000..fcf13d5 --- /dev/null +++ b/apps/backend/src/modules/middleware.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import type { NextFunction, Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import { auth, internalAuth, type AuthRequest } from "./middleware"; + +const JWT_SECRET = "test-jwt-secret"; +const INTERNAL_TOKEN = "test-internal-token"; + +function makeReq(authorization?: string): AuthRequest { + return { headers: authorization ? { authorization } : {} } as AuthRequest; +} + +function makeRes() { + const sent: { status?: number; body?: unknown } = {}; + const res = { + status(code: number) { + sent.status = code; + return this; + }, + json(body: unknown) { + sent.body = body; + return this; + }, + } as unknown as Response; + return { res, sent }; +} + +function makeNext() { + const calls: unknown[][] = []; + const next = ((...args: unknown[]) => { + calls.push(args); + }) as unknown as NextFunction; + return { next, calls }; +} + +beforeEach(() => { + process.env.JWT_SECRET = JWT_SECRET; + process.env.INTERNAL_SERVICE_TOKEN = INTERNAL_TOKEN; +}); + +describe("auth", () => { + test("attaches the decoded user and calls next for a valid JWT", () => { + const token = jwt.sign({ id: "u1", email: "user@example.com" }, JWT_SECRET); + const req = makeReq(`Bearer ${token}`); + const { res, sent } = makeRes(); + const { next, calls } = makeNext(); + + auth(req, res, next); + + expect(calls).toHaveLength(1); + expect(req.user).toMatchObject({ id: "u1", email: "user@example.com" }); + expect(sent.status).toBeUndefined(); + }); + + test("lets the internal service token through without a user", () => { + const req = makeReq(`Bearer ${INTERNAL_TOKEN}`); + const { res } = makeRes(); + const { next, calls } = makeNext(); + + auth(req, res, next); + + expect(calls).toHaveLength(1); + expect(req.user).toBeUndefined(); + }); + + test.each([ + ["a missing Authorization header", undefined], + ["a non-Bearer scheme", "Basic dXNlcjpwYXNz"], + ["a lowercase bearer prefix", "bearer sometoken"], + ])("401s on %s", (_label, header) => { + const { res, sent } = makeRes(); + const { next, calls } = makeNext(); + + auth(makeReq(header), res, next); + + expect(calls).toHaveLength(0); + expect(sent.status).toBe(401); + expect(sent.body).toEqual({ message: "Unauthorized" }); + }); + + test("401s with 'Invalid token' for a token signed with the wrong secret", () => { + const token = jwt.sign({ id: "u1", email: "user@example.com" }, "other-secret"); + const { res, sent } = makeRes(); + const { next, calls } = makeNext(); + + auth(makeReq(`Bearer ${token}`), res, next); + + expect(calls).toHaveLength(0); + expect(sent.status).toBe(401); + expect(sent.body).toEqual({ message: "Invalid token" }); + }); + + test("401s with 'Invalid token' for an expired token", () => { + const token = jwt.sign({ id: "u1", email: "user@example.com" }, JWT_SECRET, { + expiresIn: "-1s", + }); + const { res, sent } = makeRes(); + const { next } = makeNext(); + + auth(makeReq(`Bearer ${token}`), res, next); + + expect(sent.status).toBe(401); + expect(sent.body).toEqual({ message: "Invalid token" }); + }); + + test("401s with 'Invalid token' for garbage after Bearer", () => { + const { res, sent } = makeRes(); + const { next } = makeNext(); + + auth(makeReq("Bearer not-a-jwt"), res, next); + + expect(sent.status).toBe(401); + expect(sent.body).toEqual({ message: "Invalid token" }); + }); +}); + +describe("internalAuth", () => { + test("calls next when the shared secret matches", () => { + const { res, sent } = makeRes(); + const { next, calls } = makeNext(); + + internalAuth(makeReq(`Bearer ${INTERNAL_TOKEN}`) as Request, res, next); + + expect(calls).toHaveLength(1); + expect(sent.status).toBeUndefined(); + }); + + test.each([ + ["no Authorization header", undefined], + ["a non-Bearer scheme", `Basic ${INTERNAL_TOKEN}`], + ["the wrong secret", "Bearer wrong-token"], + ["an empty Bearer token", "Bearer "], + ])("401s on %s", (_label, header) => { + const { res, sent } = makeRes(); + const { next, calls } = makeNext(); + + internalAuth(makeReq(header) as Request, res, next); + + expect(calls).toHaveLength(0); + expect(sent.status).toBe(401); + expect(sent.body).toEqual({ message: "Unauthorized" }); + }); + + test("rejects a valid user JWT — this path is service-to-service only", () => { + const token = jwt.sign({ id: "u1", email: "user@example.com" }, JWT_SECRET); + const { res, sent } = makeRes(); + const { next, calls } = makeNext(); + + internalAuth(makeReq(`Bearer ${token}`) as Request, res, next); + + expect(calls).toHaveLength(0); + expect(sent.status).toBe(401); + }); +}); diff --git a/apps/backend/src/modules/user.helpers.test.ts b/apps/backend/src/modules/user.helpers.test.ts new file mode 100644 index 0000000..07de312 --- /dev/null +++ b/apps/backend/src/modules/user.helpers.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import jwt from "jsonwebtoken"; +import { isValidEmail, isValidPassword, signUserToken, toPublicUser } from "./user.helpers"; + +describe("isValidEmail", () => { + test.each([ + "user@example.com", + "first.last@sub.domain.co.in", + "user+tag@example.io", + ])("accepts %p", (email) => { + expect(isValidEmail(email)).toBe(true); + }); + + test.each([ + "", + "user", + "user@", + "@example.com", + "user@example", + "user @example.com", + "user@exam ple.com", + "two@at@example.com", + ])("rejects %p", (email) => { + expect(isValidEmail(email)).toBe(false); + }); + + test("rejects non-string values", () => { + for (const value of [undefined, null, 42, {}, ["user@example.com"]]) { + expect(isValidEmail(value)).toBe(false); + } + }); +}); + +describe("isValidPassword", () => { + test("accepts a password of exactly the 8 character minimum", () => { + expect(isValidPassword("12345678")).toBe(true); + }); + + test("rejects a password shorter than 8 characters", () => { + expect(isValidPassword("1234567")).toBe(false); + expect(isValidPassword("")).toBe(false); + }); + + test("rejects non-string values", () => { + for (const value of [undefined, null, 12345678, { password: "12345678" }]) { + expect(isValidPassword(value)).toBe(false); + } + }); +}); + +describe("signUserToken", () => { + const secret = "test-secret"; + + test("signs id and email into a token verifiable with JWT_SECRET", () => { + process.env.JWT_SECRET = secret; + + const token = signUserToken({ id: "u1", email: "user@example.com" }); + const payload = jwt.verify(token, secret) as { + id: string; + email: string; + exp: number; + iat: number; + }; + + expect(payload.id).toBe("u1"); + expect(payload.email).toBe("user@example.com"); + expect(payload.exp - payload.iat).toBe(7 * 24 * 60 * 60); + }); + + test("produces a token that fails verification under a different secret", () => { + process.env.JWT_SECRET = secret; + const token = signUserToken({ id: "u1", email: "user@example.com" }); + + expect(() => jwt.verify(token, "other-secret")).toThrow(); + }); + + test("does not leak fields beyond id and email", () => { + process.env.JWT_SECRET = secret; + const token = signUserToken({ + id: "u1", + email: "user@example.com", + password: "hashed", + } as { id: string; email: string }); + + expect(Object.keys(jwt.decode(token) as object).sort()).toEqual([ + "email", + "exp", + "iat", + "id", + ]); + }); +}); + +describe("toPublicUser", () => { + test("keeps only the publicly safe fields", () => { + const createdAt = new Date("2026-01-01T00:00:00.000Z"); + const user = { + id: "u1", + email: "user@example.com", + name: "Ada", + createdAt, + password: "hashed-secret", + googleId: "g1", + }; + + const publicUser = toPublicUser(user); + + expect(publicUser).toEqual({ + id: "u1", + email: "user@example.com", + name: "Ada", + createdAt, + }); + expect(Object.keys(publicUser)).not.toContain("password"); + }); + + test("preserves a null name", () => { + expect( + toPublicUser({ + id: "u1", + email: "user@example.com", + name: null, + createdAt: new Date(0), + }).name, + ).toBeNull(); + }); +}); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index d60d44e..2c7075a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -8,6 +8,7 @@ "build": "tsc && vite build", "lint": "eslint .", "check-types": "tsc --noEmit", + "test": "bun test src", "preview": "vite preview" }, "dependencies": { @@ -28,6 +29,7 @@ "devDependencies": { "@repo/eslint-config": "*", "@repo/typescript-config": "*", + "@types/bun": "latest", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.1.1", "@types/react": "^19.2.17", diff --git a/apps/frontend/src/features/build/describeEvent.test.ts b/apps/frontend/src/features/build/describeEvent.test.ts new file mode 100644 index 0000000..ad3afe2 --- /dev/null +++ b/apps/frontend/src/features/build/describeEvent.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import type { OrchestratorEvent } from "../../../../../packages/agents/agent/events"; +import { describeEvent } from "./describeEvent"; + +describe("describeEvent", () => { + test("describes the orchestrator lifecycle events", () => { + expect(describeEvent({ type: "orchestrator_agent_started" })).toBe("Planning the build…"); + expect(describeEvent({ type: "main_agent_success" })).toBe("Agent finished its task."); + expect(describeEvent({ type: "orchestrator_completed", summary: "Shipped the app." })).toBe( + "Shipped the app.", + ); + }); + + test("names the tool for a main agent tool call", () => { + expect(describeEvent({ type: "main_agent_tool_call", step: 3, toolName: "writeFile" })).toBe( + "Running writeFile…", + ); + }); + + test("maps every main_agent_progress step", () => { + expect(describeEvent({ type: "main_agent_progress", step: "toolCall", toolCall: "readFile" })).toBe( + "Calling readFile…", + ); + expect(describeEvent({ type: "main_agent_progress", step: "llm_completed" })).toBe( + "Model responded.", + ); + expect(describeEvent({ type: "main_agent_progress", step: "llm_failed" })).toBe( + "Model call failed.", + ); + }); + + test("falls back to 'a tool' when a toolCall step carries no tool name", () => { + expect(describeEvent({ type: "main_agent_progress", step: "toolCall" })).toBe( + "Calling a tool…", + ); + }); + + test("prefers a subagent's own summary over the generic working message", () => { + expect( + describeEvent({ type: "subagent_progress", agent: "coder", subagentSummary: "Wrote App.tsx" }), + ).toBe("Wrote App.tsx"); + expect(describeEvent({ type: "subagent_progress", agent: "coder" })).toBe("coder is working…"); + }); + + test("describes subagent completion with its summary", () => { + expect( + describeEvent({ type: "subagent_completed", agent: "tester", taskId: 2, summary: "all green" }), + ).toBe("tester finished: all green"); + }); + + test("falls back to 'Working…' for events with no user-facing copy", () => { + const events: OrchestratorEvent[] = [ + { type: "clarification_needed", questions: [] }, + { type: "select_design", designs: [] }, + { type: "run_failed", error: "boom" }, + ]; + + for (const event of events) { + expect(describeEvent(event)).toBe("Working…"); + } + }); +}); diff --git a/apps/frontend/src/lib/api.test.ts b/apps/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..602dde6 --- /dev/null +++ b/apps/frontend/src/lib/api.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { ApiError, api } from "./api"; +import { setStoredSession, type Session } from "./session"; + +interface Call { + url: string; + init: RequestInit; +} + +const realFetch = globalThis.fetch; +let calls: Call[]; + +function stubFetch(response: { status?: number; body?: unknown; invalidJson?: boolean }) { + globalThis.fetch = (async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + const status = response.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + json: async () => { + if (response.invalidJson) throw new Error("Unexpected end of JSON input"); + return response.body; + }, + } as Response; + }) as typeof fetch; +} + +function headersOf(call: Call): Headers { + return call.init.headers as Headers; +} + +const session: Session = { + token: "jwt-token", + user: { id: "u1", email: "user@example.com", name: "Ada", createdAt: "2026-01-01T00:00:00.000Z" }, +}; + +beforeEach(() => { + calls = []; + const store = new Map(); + (globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }; +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("api verbs", () => { + test("get issues a GET with no body and no content type", async () => { + stubFetch({ body: { ok: true } }); + + const result = await api.get<{ ok: boolean }>("/api/projects"); + + expect(result).toEqual({ ok: true }); + expect(calls[0]!.url).toBe("/api/projects"); + expect(calls[0]!.init.method).toBe("GET"); + expect(calls[0]!.init.body).toBeUndefined(); + expect(headersOf(calls[0]!).get("Content-Type")).toBeNull(); + }); + + test("post serializes the body as JSON and sets the content type", async () => { + stubFetch({ body: { id: "p1" } }); + + await api.post("/api/projects", { name: "site" }); + + expect(calls[0]!.init.method).toBe("POST"); + expect(calls[0]!.init.body).toBe(JSON.stringify({ name: "site" })); + expect(headersOf(calls[0]!).get("Content-Type")).toBe("application/json"); + }); + + test("patch sends a PATCH with the serialized body", async () => { + stubFetch({ body: { id: "p1", starred: true } }); + + await api.patch("/api/projects/p1", { starred: true }); + + expect(calls[0]!.init.method).toBe("PATCH"); + expect(calls[0]!.init.body).toBe(JSON.stringify({ starred: true })); + }); + + test("post without a body sends no body and no content type", async () => { + stubFetch({ body: {} }); + + await api.post("/api/runs/r1/cancel"); + + expect(calls[0]!.init.body).toBeUndefined(); + expect(headersOf(calls[0]!).get("Content-Type")).toBeNull(); + }); +}); + +describe("api auth headers", () => { + test("sends the bearer token and the raw userid header when a session exists", async () => { + setStoredSession(session); + stubFetch({ body: {} }); + + await api.get("/api/projects"); + + expect(headersOf(calls[0]!).get("Authorization")).toBe("Bearer jwt-token"); + expect(headersOf(calls[0]!).get("userid")).toBe("u1"); + }); + + test("omits auth headers when there is no stored session", async () => { + stubFetch({ body: {} }); + + await api.get("/api/projects"); + + expect(headersOf(calls[0]!).get("Authorization")).toBeNull(); + expect(headersOf(calls[0]!).get("userid")).toBeNull(); + }); + + test("omits auth headers when auth is explicitly disabled", async () => { + setStoredSession(session); + stubFetch({ body: {} }); + + await api.post("/api/auth/login", { email: "user@example.com" }, { auth: false }); + + expect(headersOf(calls[0]!).get("Authorization")).toBeNull(); + expect(headersOf(calls[0]!).get("Content-Type")).toBe("application/json"); + }); + + test("keeps caller-supplied headers alongside the auth headers", async () => { + setStoredSession(session); + stubFetch({ body: {} }); + + await api.get("/api/projects", { headers: { "X-Trace": "abc" } }); + + expect(headersOf(calls[0]!).get("X-Trace")).toBe("abc"); + expect(headersOf(calls[0]!).get("Authorization")).toBe("Bearer jwt-token"); + }); +}); + +describe("api error handling", () => { + test("throws an ApiError carrying the status and the server message", async () => { + stubFetch({ status: 404, body: { message: "Project not found" } }); + + const error = (await api.get("/api/projects/nope").catch((e) => e)) as ApiError; + + expect(error).toBeInstanceOf(ApiError); + expect(error.status).toBe(404); + expect(error.message).toBe("Project not found"); + }); + + test("falls back to a generic message when the error payload has none", async () => { + stubFetch({ status: 500, body: {} }); + + const error = (await api.get("/api/projects").catch((e) => e)) as ApiError; + + expect(error.status).toBe(500); + expect(error.message).toBe("Request failed (500)"); + }); + + test("falls back to a generic message when the error body is not JSON", async () => { + stubFetch({ status: 502, invalidJson: true }); + + const error = (await api.get("/api/projects").catch((e) => e)) as ApiError; + + expect(error.status).toBe(502); + expect(error.message).toBe("Request failed (502)"); + }); + + test("resolves to null when a successful response has no JSON body", async () => { + stubFetch({ status: 204, invalidJson: true }); + + expect(await api.get("/api/projects")).toBeNull(); + }); +}); diff --git a/apps/frontend/src/lib/session.test.ts b/apps/frontend/src/lib/session.test.ts new file mode 100644 index 0000000..d131541 --- /dev/null +++ b/apps/frontend/src/lib/session.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { getStoredSession, setStoredSession, type Session } from "./session"; + +const STORAGE_KEY = "lovable.session"; + +function installLocalStorage() { + const store = new Map(); + const localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + clear: () => store.clear(), + }; + (globalThis as { localStorage?: unknown }).localStorage = localStorage; + return store; +} + +const session: Session = { + token: "jwt-token", + user: { + id: "u1", + email: "user@example.com", + name: "Ada", + createdAt: "2026-01-01T00:00:00.000Z", + }, +}; + +let store: Map; + +beforeEach(() => { + store = installLocalStorage(); +}); + +describe("setStoredSession", () => { + test("writes the session as JSON under the lovable.session key", () => { + setStoredSession(session); + + expect(JSON.parse(store.get(STORAGE_KEY)!)).toEqual(session); + }); + + test("clears the key when passed null", () => { + store.set(STORAGE_KEY, JSON.stringify(session)); + + setStoredSession(null); + + expect(store.has(STORAGE_KEY)).toBe(false); + }); + + test("overwrites a previously stored session", () => { + setStoredSession(session); + setStoredSession({ ...session, token: "new-token" }); + + expect(getStoredSession()?.token).toBe("new-token"); + }); +}); + +describe("getStoredSession", () => { + test("returns null when nothing is stored", () => { + expect(getStoredSession()).toBeNull(); + }); + + test("round-trips a session written by setStoredSession", () => { + setStoredSession(session); + + expect(getStoredSession()).toEqual(session); + }); + + test("returns null instead of throwing on corrupted JSON", () => { + store.set(STORAGE_KEY, "{not-json"); + + expect(getStoredSession()).toBeNull(); + }); + + test("returns null for an empty stored value", () => { + store.set(STORAGE_KEY, ""); + + expect(getStoredSession()).toBeNull(); + }); +}); diff --git a/apps/frontend/src/lib/sse.test.ts b/apps/frontend/src/lib/sse.test.ts new file mode 100644 index 0000000..b5217ea --- /dev/null +++ b/apps/frontend/src/lib/sse.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { setStoredSession, type Session } from "./session"; +import { openEventStream } from "./sse"; + +const realFetch = globalThis.fetch; +const encoder = new TextEncoder(); + +interface Call { + url: string; + init: RequestInit; +} + +let calls: Call[]; + +// Streams the given chunks, one per read, so the parser sees the same partial +// frames it would over the wire. +function stubStream(chunks: string[], opts: { status?: number; withBody?: boolean } = {}) { + const status = opts.status ?? 200; + globalThis.fetch = (async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + let i = 0; + const body = { + getReader: () => ({ + read: async () => + i < chunks.length + ? { value: encoder.encode(chunks[i++]!), done: false } + : { value: undefined, done: true }, + }), + }; + return { + ok: status >= 200 && status < 300, + status, + body: opts.withBody === false ? null : body, + } as unknown as Response; + }) as typeof fetch; +} + +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +const session: Session = { + token: "jwt-token", + user: { id: "u1", email: "user@example.com", name: "Ada", createdAt: "2026-01-01T00:00:00.000Z" }, +}; + +beforeEach(() => { + calls = []; + const store = new Map(); + (globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }; +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("openEventStream parsing", () => { + test("emits the data payload of each complete frame", async () => { + stubStream(['data: {"type":"a"}\n\n', 'data: {"type":"b"}\n\n']); + const messages: string[] = []; + + openEventStream("/api/runs/r1/stream", { onMessage: (d) => messages.push(d) }); + await flush(); + + expect(messages).toEqual(['{"type":"a"}', '{"type":"b"}']); + }); + + test("reassembles a frame split across chunks", async () => { + stubStream(['data: {"ty', 'pe":"a"}\n', '\ndata: {"type":"b"}\n\n']); + const messages: string[] = []; + + openEventStream("/api/runs/r1/stream", { onMessage: (d) => messages.push(d) }); + await flush(); + + expect(messages).toEqual(['{"type":"a"}', '{"type":"b"}']); + }); + + test("handles several frames arriving in one chunk", async () => { + stubStream(["data: one\n\ndata: two\n\ndata: three\n\n"]); + const messages: string[] = []; + + openEventStream("/api/runs/r1/stream", { onMessage: (d) => messages.push(d) }); + await flush(); + + expect(messages).toEqual(["one", "two", "three"]); + }); + + test("ignores comment/heartbeat frames that carry no data line", async () => { + stubStream([": keep-alive\n\n", "event: ping\n\n", "data: real\n\n"]); + const messages: string[] = []; + + openEventStream("/api/runs/r1/stream", { onMessage: (d) => messages.push(d) }); + await flush(); + + expect(messages).toEqual(["real"]); + }); + + test("reads the data line even when it follows an event line", async () => { + stubStream(["event: progress\ndata: payload\n\n"]); + const messages: string[] = []; + + openEventStream("/api/runs/r1/stream", { onMessage: (d) => messages.push(d) }); + await flush(); + + expect(messages).toEqual(["payload"]); + }); + + test("drops a trailing frame that never got its blank-line terminator", async () => { + stubStream(["data: complete\n\ndata: incomplete"]); + const messages: string[] = []; + + openEventStream("/api/runs/r1/stream", { onMessage: (d) => messages.push(d) }); + await flush(); + + expect(messages).toEqual(["complete"]); + }); +}); + +describe("openEventStream auth", () => { + test("sends the bearer token and userid header when a session exists", async () => { + setStoredSession(session); + stubStream([]); + + openEventStream("/api/runs/r1/stream", { onMessage: () => {} }); + await flush(); + + expect(calls[0]!.url).toBe("/api/runs/r1/stream"); + expect(calls[0]!.init.headers).toEqual({ + Authorization: "Bearer jwt-token", + userid: "u1", + }); + }); + + test("sends no auth headers when there is no session", async () => { + stubStream([]); + + openEventStream("/api/runs/r1/stream", { onMessage: () => {} }); + await flush(); + + expect(calls[0]!.init.headers).toEqual({}); + }); +}); + +describe("openEventStream failure handling", () => { + test("reports a non-ok response through onError", async () => { + stubStream([], { status: 500 }); + const errors: unknown[] = []; + + openEventStream("/api/runs/r1/stream", { onMessage: () => {}, onError: (e) => errors.push(e) }); + await flush(); + + expect((errors[0] as Error).message).toBe("Stream request failed (500)"); + }); + + test("reports a missing response body through onError", async () => { + stubStream([], { withBody: false }); + const errors: unknown[] = []; + + openEventStream("/api/runs/r1/stream", { onMessage: () => {}, onError: (e) => errors.push(e) }); + await flush(); + + expect((errors[0] as Error).message).toBe("Stream request failed (200)"); + }); + + test("swallows the abort triggered by the returned cleanup function", async () => { + globalThis.fetch = ((_url: string, init: RequestInit = {}) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(new Error("The operation was aborted."))); + })) as typeof fetch; + const errors: unknown[] = []; + + const close = openEventStream("/api/runs/r1/stream", { + onMessage: () => {}, + onError: (e) => errors.push(e), + }); + close(); + await flush(); + + expect(errors).toEqual([]); + }); + + test("tolerates a missing onError handler", async () => { + stubStream([], { status: 500 }); + + expect(() => openEventStream("/api/runs/r1/stream", { onMessage: () => {} })).not.toThrow(); + await flush(); + }); +}); diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index 3dcdf23..b997085 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -6,7 +6,7 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "types": ["vite/client", "node"], + "types": ["vite/client", "node", "bun"], "baseUrl": ".", "ignoreDeprecations": "5.0", "paths": { diff --git a/bun.lock b/bun.lock index 5bc66de..ffc4ee6 100644 --- a/bun.lock +++ b/bun.lock @@ -67,6 +67,7 @@ "@repo/eslint-config": "*", "@repo/typescript-config": "*", "@tailwindcss/vite": "^4.3.3", + "@types/bun": "latest", "@types/node": "^26.1.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/package.json b/package.json index b55307e..9db640d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "turbo run dev", "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"", - "check-types": "turbo run check-types" + "check-types": "turbo run check-types", + "test": "turbo run test" }, "devDependencies": { "prettier": "^3.7.4", diff --git a/packages/agents/agent/services/dag.test.ts b/packages/agents/agent/services/dag.test.ts new file mode 100644 index 0000000..0c27e04 --- /dev/null +++ b/packages/agents/agent/services/dag.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import type { PlannerTodo } from "../../baml_client"; +import { DAG } from "./dag"; + +function todo(id: number, dependency: number[] = []): PlannerTodo { + return { + id, + task: `task ${id}`, + agent: "coder", + status: "pending", + dependency, + designNeeded: false, + }; +} + +function isBefore(ids: number[], a: number, b: number): boolean { + return ids.indexOf(a) < ids.indexOf(b); +} + +describe("DAG.makeGraph", () => { + test("maps every task id to its dependency list", () => { + const dag = new DAG([todo(1), todo(2, [1]), todo(3, [1, 2])]); + const graph = dag.makeGraph(dag.todos); + + expect([...graph.entries()]).toEqual([ + [1, []], + [2, [1]], + [3, [1, 2]], + ]); + }); +}); + +describe("DAG.TopologicalSort", () => { + test("returns an empty list for no todos", () => { + expect(new DAG([]).TopologicalSort()).toEqual([]); + }); + + test("emits dependencies before the tasks that need them", () => { + const todos = [todo(1, [2]), todo(2, [3]), todo(3)]; + const ids = new DAG(todos).TopologicalSort().map((t) => t.id); + + expect(ids).toEqual([3, 2, 1]); + }); + + test("keeps a diamond dependency in a valid order", () => { + // 1 <- 2, 1 <- 3, {2,3} <- 4 + const todos = [todo(1), todo(2, [1]), todo(3, [1]), todo(4, [2, 3])]; + const ids = new DAG(todos).TopologicalSort().map((t) => t.id); + + expect(ids).toHaveLength(4); + expect(isBefore(ids, 1, 2)).toBe(true); + expect(isBefore(ids, 1, 3)).toBe(true); + expect(isBefore(ids, 2, 4)).toBe(true); + expect(isBefore(ids, 3, 4)).toBe(true); + }); + + test("includes tasks from disconnected components", () => { + const todos = [todo(1), todo(2, [1]), todo(3), todo(4, [3])]; + const ids = new DAG(todos).TopologicalSort().map((t) => t.id); + + expect(ids.sort()).toEqual([1, 2, 3, 4]); + }); + + test("returns the original todo objects, not copies", () => { + const todos = [todo(1), todo(2, [1])]; + const sorted = new DAG(todos).TopologicalSort(); + + expect(sorted[0]).toBe(todos[0]); + expect(sorted[1]).toBe(todos[1]); + }); + + test("drops dependencies pointing at unknown task ids", () => { + const todos = [todo(1, [99]), todo(2, [1])]; + const ids = new DAG(todos).TopologicalSort().map((t) => t.id); + + expect(ids).toEqual([1, 2]); + }); + + test("throws on a cycle", () => { + const todos = [todo(1, [2]), todo(2, [1])]; + + expect(() => new DAG(todos).TopologicalSort()).toThrow(/Cycle detected/); + }); + + test("throws on a task depending on itself", () => { + expect(() => new DAG([todo(1, [1])]).TopologicalSort()).toThrow(/Cycle detected/); + }); + + test("is idempotent across repeated calls", () => { + const dag = new DAG([todo(1), todo(2, [1]), todo(3, [2])]); + + expect(dag.TopologicalSort().map((t) => t.id)).toEqual([1, 2, 3]); + expect(dag.TopologicalSort().map((t) => t.id)).toEqual([1, 2, 3]); + }); +}); + +describe("DAG.topoSortParallel", () => { + test("returns no batches for no todos", () => { + expect(new DAG([]).topoSortParallel()).toEqual([]); + }); + + test("puts independent tasks in the same batch", () => { + const todos = [todo(1), todo(2), todo(3, [1, 2])]; + + expect(new DAG(todos).topoSortParallel()).toEqual([[1, 2], [3]]); + }); + + test("serializes a dependency chain into one batch per task", () => { + const todos = [todo(1), todo(2, [1]), todo(3, [2])]; + + expect(new DAG(todos).topoSortParallel()).toEqual([[1], [2], [3]]); + }); + + test("waits for every dependency of a task before scheduling it", () => { + // 3 depends on 2, which depends on 1; 4 only depends on 1 + const todos = [todo(1), todo(2, [1]), todo(3, [2]), todo(4, [1])]; + + expect(new DAG(todos).topoSortParallel()).toEqual([[1], [2, 4], [3]]); + }); + + test("throws on a cycle instead of looping forever", () => { + const todos = [todo(1, [2]), todo(2, [1])]; + + expect(() => new DAG(todos).topoSortParallel()).toThrow(/Cycle detected/); + }); + + test("throws when a dependency id does not exist", () => { + expect(() => new DAG([todo(1, [99])]).topoSortParallel()).toThrow(/Cycle detected/); + }); +}); diff --git a/packages/agents/agent/skills/skills.test.ts b/packages/agents/agent/skills/skills.test.ts new file mode 100644 index 0000000..99b664d --- /dev/null +++ b/packages/agents/agent/skills/skills.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import type { Skill } from "../../baml_client"; +import { SkillStore } from "./index"; + +describe("SkillStore.getRoleSkills", () => { + test("loads the role skills declared for an agent, with content", async () => { + const skills = await new SkillStore().getRoleSkills("coder"); + + expect(skills.map((s) => s.name)).toEqual(["design-system", "dependency-policy"]); + for (const skill of skills) { + expect(skill.description.length).toBeGreaterThan(0); + expect(skill.content?.length ?? 0).toBeGreaterThan(0); + } + }); + + test("resolves the uiExpert design-system override to the design-ui folder", async () => { + const [uiDesign] = await new SkillStore().getRoleSkills("uiExpert"); + const raw = await Bun.file(`${import.meta.dir}/design-ui/SKILL.md`).text(); + + expect(uiDesign?.name).toBe("design-system"); + expect(uiDesign?.content).toBe(raw.split(/^---$/m)[2]!.trim()); + }); + + test("returns no role skills for an agent that declares none", async () => { + expect(await new SkillStore().getRoleSkills("debuggerr")).toHaveLength(1); + expect(await new SkillStore().getTaskSkillsFull("debuggerr")).toEqual([]); + }); +}); + +describe("SkillStore.globalSkills", () => { + test("always includes project-conventions", async () => { + const skills = await new SkillStore().globalSkills(); + + expect(skills.map((s) => s.name)).toEqual(["project-conventions"]); + }); +}); + +describe("SkillStore.getTaskCatalog", () => { + test("strips content so the catalog stays cheap to inline", async () => { + const store = new SkillStore(); + const catalog = await store.getTaskCatalog("tester"); + const full = await store.getTaskSkillsFull("tester"); + + expect(catalog.map((s) => s.name)).toEqual(full.map((s) => s.name)); + expect(catalog.every((s) => s.content === null)).toBe(true); + expect(full.every((s) => (s.content?.length ?? 0) > 0)).toBe(true); + }); +}); + +describe("SkillStore.fetchSkillContent", () => { + test("resolves a skill name back to its file content", async () => { + const store = new SkillStore(); + const content = await store.fetchSkillContent("add-a-route"); + const [expected] = (await store.getTaskSkillsFull("coder")).filter((s) => s.name === "add-a-route"); + + expect(content).toBe(expected?.content ?? ""); + }); + + test("throws for an unknown skill name", async () => { + expect(new SkillStore().fetchSkillContent("does-not-exist")).rejects.toThrow( + /Unknown skill requested via getSkill/, + ); + }); +}); + +describe("SkillStore.renderAsText", () => { + const store = new SkillStore(); + + test("renders loaded skills with their body", () => { + const skills: Skill[] = [{ name: "a", description: "desc a", content: "body a" }]; + + expect(store.renderAsText(skills)).toBe("## a\ndesc a\n\nbody a"); + }); + + test("renders catalog entries as a getSkill hint", () => { + const skills: Skill[] = [{ name: "a", description: "desc a", content: null }]; + + expect(store.renderAsText(skills)).toBe( + '## a (call getSkill("a") to load full content)\ndesc a', + ); + }); + + test("separates multiple skills with a blank line", () => { + const skills: Skill[] = [ + { name: "a", description: "desc a", content: "body a" }, + { name: "b", description: "desc b", content: "body b" }, + ]; + + expect(store.renderAsText(skills)).toBe("## a\ndesc a\n\nbody a\n\n## b\ndesc b\n\nbody b"); + }); + + test("returns an empty string for no skills", () => { + expect(store.renderAsText([])).toBe(""); + }); +}); + +describe("SkillStore caching", () => { + test("reuses the same Skill object for repeated loads of one folder", async () => { + const store = new SkillStore(); + const first = await store.fetchSkillContent("layout-patterns"); + const [roleSkill] = (await store.getTaskSkillsFull("uiExpert")).filter( + (s) => s.name === "layout-patterns", + ); + + expect(roleSkill?.content).toBe(first); + }); +}); diff --git a/packages/agents/agent/utils/context.test.ts b/packages/agents/agent/utils/context.test.ts new file mode 100644 index 0000000..2422080 --- /dev/null +++ b/packages/agents/agent/utils/context.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { CoderContext, DebuggerContext, Message } from "../../baml_client"; +import { RECENT_TURNS_LIMIT } from "../config/systemConfig"; + +// The generated baml client boots its native runtime on import, which stalls the +// test runner — and these context reducers only need the LLM calls stubbed out. +const bamlCalls: { name: string; prompt: string; context: unknown }[] = []; +const record = + (name: string, reply: (context: T) => T) => + async (prompt: string, context: T): Promise => { + bamlCalls.push({ name, prompt, context }); + return reply(context); + }; + +mock.module("../../baml_client", () => ({ + b: { + CompactCoderContext: record("CompactCoderContext", (context) => ({ + ...context, + dependentSummary: [{ taskId: "compacted", summary: "compacted summary" }], + recentTurns: [turn(0)], + })), + CompactDebuggerContext: record("CompactDebuggerContext", (context) => ({ + ...context, + fixHistory: [{ error: context.originalError, fixSummary: "compacted fixes" }], + recentTurns: [turn(0)], + })), + SummarizeCoderContext: record("SummarizeCoderContext", (context) => ({ + ...context, + task: "summarized task", + })), + SummarizeDebuggerContext: record("SummarizeDebuggerContext", (context) => ({ + ...context, + originalError: "summarized error", + })), + }, +})); + +const { CoderContextManager, DebuggerContextManager } = await import("./context"); + +function turn(i: number): Message { + return { role: "toolCall", content: `turn ${i}`, timestamp: "2026-01-01T00:00:00.000Z" }; +} + +function coderContext(overrides: Partial = {}): CoderContext { + return { + task: "build a landing page", + dependentSummary: [{ taskId: "1", summary: "scaffolded the app" }], + repoTree: "src/index.ts", + skills: [{ name: "design-system", description: "tokens", content: "body" }], + recentTurns: [], + ...overrides, + }; +} + +function debuggerContext(overrides: Partial = {}): DebuggerContext { + return { + repoTree: "src/index.ts", + originalError: "TS2304: Cannot find name 'foo'", + fixHistory: [], + skills: [], + recentTurns: [], + ...overrides, + }; +} + +beforeEach(() => { + bamlCalls.length = 0; +}); + +describe("CoderContextManager.appendTurn", () => { + const manager = new CoderContextManager(); + + test("appends one summarized turn and leaves the rest of the context untouched", () => { + const context = coderContext(); + const next = manager.appendTurn(context, { action: "writeFile", path: "src/App.tsx" }, { + response: "written", + }); + + expect(next.recentTurns).toHaveLength(1); + expect(next.recentTurns[0]).toEqual({ + role: "toolCall", + content: "writeFile src/App.tsx -> written", + timestamp: next.recentTurns[0]!.timestamp, + }); + expect(next.task).toBe(context.task); + expect(next.dependentSummary).toBe(context.dependentSummary); + expect(next.repoTree).toBe(context.repoTree); + expect(next.skills).toBe(context.skills); + }); + + test("does not mutate the context it was given", () => { + const context = coderContext(); + manager.appendTurn(context, { action: "runCommand", command: "bun test" }, { response: "ok" }); + + expect(context.recentTurns).toEqual([]); + }); + + test("stamps an ISO timestamp on the appended turn", () => { + const next = manager.appendTurn(coderContext(), { action: "readFile" }, { response: "ok" }); + + expect(next.recentTurns[0]!.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/); + }); + + test("labels the turn with command or skillName when there is no path", () => { + const withCommand = manager.appendTurn(coderContext(), { action: "runCommand", command: "ls" }, { + response: "ok", + }); + const withSkill = manager.appendTurn(coderContext(), { action: "getSkill", skillName: "add-a-route" }, { + response: "loaded", + }); + + expect(withCommand.recentTurns[0]!.content).toBe("runCommand ls -> ok"); + expect(withSkill.recentTurns[0]!.content).toBe("getSkill add-a-route -> loaded"); + }); + + test("falls back to 'unknown' with no label when the action is missing", () => { + const next = manager.appendTurn(coderContext(), {}, { response: "ok" }); + + expect(next.recentTurns[0]!.content).toBe("unknown -> ok"); + }); + + test("prefers response, then editedFiles, then toolResult, then JSON for the result text", () => { + const of = (toolRes: unknown) => + manager.appendTurn(coderContext(), { action: "act" }, toolRes).recentTurns[0]!.content; + + expect(of({ response: "r", editedFiles: "e", toolResult: "t" })).toBe("act -> r"); + expect(of({ editedFiles: "e", toolResult: "t" })).toBe("act -> e"); + expect(of({ toolResult: "t" })).toBe("act -> t"); + expect(of({ other: 1 })).toBe('act -> {"other":1}'); + expect(of({ response: { nested: true } })).toBe('act -> {"response":{"nested":true}}'); + }); + + test("keeps only the most recent RECENT_TURNS_LIMIT turns", () => { + const existing = Array.from({ length: RECENT_TURNS_LIMIT }, (_, i) => turn(i)); + const next = manager.appendTurn(coderContext({ recentTurns: existing }), { action: "act" }, { + response: "newest", + }); + + expect(next.recentTurns).toHaveLength(RECENT_TURNS_LIMIT); + expect(next.recentTurns[0]!.content).toBe("turn 1"); + expect(next.recentTurns.at(-1)!.content).toBe("act -> newest"); + }); +}); + +describe("CoderContextManager.CompactContext", () => { + const manager = new CoderContextManager(); + + test("only sends the older half of the dependent summaries to the model", async () => { + const dependentSummary = Array.from({ length: 4 }, (_, i) => ({ + taskId: String(i), + summary: `summary ${i}`, + })); + + await manager.CompactContext(coderContext({ dependentSummary })); + + expect(bamlCalls).toHaveLength(1); + expect(bamlCalls[0]!.name).toBe("CompactCoderContext"); + expect((bamlCalls[0]!.context as CoderContext).dependentSummary).toEqual(dependentSummary.slice(0, 2)); + }); + + test("keeps the recent half verbatim after the compacted older half", async () => { + const dependentSummary = Array.from({ length: 4 }, (_, i) => ({ + taskId: String(i), + summary: `summary ${i}`, + })); + + const compacted = await manager.CompactContext(coderContext({ dependentSummary })); + + expect(compacted.dependentSummary).toEqual([ + { taskId: "compacted", summary: "compacted summary" }, + ...dependentSummary.slice(2), + ]); + expect(compacted.recentTurns).toEqual([turn(0)]); + }); + + test("carries task, repoTree and skills through untouched", async () => { + const context = coderContext(); + const compacted = await manager.CompactContext(context); + + expect(compacted.task).toBe(context.task); + expect(compacted.repoTree).toBe(context.repoTree); + expect(compacted.skills).toBe(context.skills); + }); +}); + +describe("CoderContextManager other reductions", () => { + const manager = new CoderContextManager(); + + test("SummarizeContext hands the whole context to the model", async () => { + const context = coderContext(); + const summarized = await manager.SummarizeContext(context); + + expect(bamlCalls.map((c) => c.name)).toEqual(["SummarizeCoderContext"]); + expect(bamlCalls[0]!.context).toBe(context); + expect(summarized.task).toBe("summarized task"); + }); + + test("IsolateContext and OffLoadContext are still stubs", async () => { + expect(await manager.IsolateContext()).toBe("TODO: implement this"); + expect(await manager.OffLoadContext()).toBe("TODO: implement this"); + }); +}); + +describe("DebuggerContextManager.appendTurn", () => { + const manager = new DebuggerContextManager(); + + test("records a fix attempt against the original error", () => { + const context = debuggerContext(); + const next = manager.appendTurn(context, { action: "editFile" }, { message: "added the import" }); + + expect(next.fixHistory).toEqual([ + { error: context.originalError, fixSummary: "editFile: added the import" }, + ]); + expect(next.recentTurns).toHaveLength(1); + expect(context.fixHistory).toEqual([]); + }); + + test("prefers message, then summary, then truncated JSON for the fix summary", () => { + const summaryOf = (toolRes: Record) => + manager.appendTurn(debuggerContext(), { action: "act" }, toolRes).fixHistory[0]!.fixSummary; + + expect(summaryOf({ message: "m", summary: "s" })).toBe("act: m"); + expect(summaryOf({ summary: "s" })).toBe("act: s"); + expect(summaryOf({ code: 2 })).toBe('act: {"code":2}'); + }); + + test("truncates a long JSON fix summary to 500 characters", () => { + const fixSummary = manager.appendTurn(debuggerContext(), { action: "act" }, { + stdout: "x".repeat(2000), + }).fixHistory[0]!.fixSummary; + + expect(fixSummary.startsWith("act: ")).toBe(true); + expect(fixSummary.length).toBe("act: ".length + 500); + }); + + test("appends to existing fix history without dropping earlier attempts", () => { + const context = debuggerContext({ + fixHistory: [{ error: "old error", fixSummary: "first try" }], + }); + const next = manager.appendTurn(context, { action: "act" }, { message: "second try" }); + + expect(next.fixHistory.map((f) => f.fixSummary)).toEqual(["first try", "act: second try"]); + }); + + test("keeps only the most recent RECENT_TURNS_LIMIT turns", () => { + const existing = Array.from({ length: RECENT_TURNS_LIMIT + 5 }, (_, i) => turn(i)); + const next = manager.appendTurn(debuggerContext({ recentTurns: existing }), { action: "act" }, { + response: "newest", + }); + + expect(next.recentTurns).toHaveLength(RECENT_TURNS_LIMIT); + expect(next.recentTurns.at(-1)!.content).toBe("act -> newest"); + }); +}); + +describe("DebuggerContextManager.CompactContext", () => { + const manager = new DebuggerContextManager(); + + test("compacts the older half of the fix history and keeps the recent half", async () => { + const fixHistory = Array.from({ length: 4 }, (_, i) => ({ + error: `error ${i}`, + fixSummary: `fix ${i}`, + })); + const context = debuggerContext({ fixHistory }); + + const compacted = await manager.CompactContext(context); + + expect((bamlCalls[0]!.context as DebuggerContext).fixHistory).toEqual(fixHistory.slice(0, 2)); + expect(compacted.fixHistory).toEqual([ + { error: context.originalError, fixSummary: "compacted fixes" }, + ...fixHistory.slice(2), + ]); + expect(compacted.originalError).toBe(context.originalError); + expect(compacted.repoTree).toBe(context.repoTree); + }); + + test("SummarizeContext hands the whole context to the model", async () => { + const summarized = await manager.SummarizeContext(debuggerContext()); + + expect(bamlCalls.map((c) => c.name)).toEqual(["SummarizeDebuggerContext"]); + expect(summarized.originalError).toBe("summarized error"); + }); + + test("IsolateContext and OffLoadContext are still stubs", async () => { + expect(await manager.IsolateContext()).toBe("TODO: implement this"); + expect(await manager.OffLoadContext()).toBe("TODO: implement this"); + }); +}); diff --git a/packages/agents/agent/utils/sb.test.ts b/packages/agents/agent/utils/sb.manual.ts similarity index 100% rename from packages/agents/agent/utils/sb.test.ts rename to packages/agents/agent/utils/sb.manual.ts diff --git a/packages/agents/package.json b/packages/agents/package.json index 58a8fd5..5e6785c 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -6,7 +6,8 @@ "./*": "./src/*.tsx" }, "scripts": { - "baml-generate": "baml-cli generate" + "baml-generate": "baml-cli generate", + "test": "bun test agent" }, "private": true, "devDependencies": { diff --git a/turbo.json b/turbo.json index 6bde259..faa6cf2 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,10 @@ "check-types": { "dependsOn": ["^check-types"] }, + "test": { + "dependsOn": ["^test"], + "outputs": [] + }, "dev": { "cache": false, "persistent": true