/** * Integration tests for the Session index: creation (default model / workspace * guard), listing (DB union Trace directory discovery), PATCH approval mode, * and createdAt parsing. */ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { sessionMeta, userText } from "@prismshadow/penguin-core"; import type { OmniMessage, SessionMetaPayload } from "@prismshadow/penguin-core"; import type { ProjectCreateResponse, SessionCreateResponse, SessionResponse, SessionsResponse, } from "../src/api/types.js"; import { sessionIdCreatedAt } from "../src/services/session-service.js"; import { apiClient, createTestApp, provisionUser, writeTraceFile } from "./helpers.js"; import type { TestApp } from "./helpers.js"; describe("session-index", () => { let t: TestApp; let api: ReturnType; let projectId: string; const base = () => `/api/projects/${projectId}/agents/default_agent/sessions`; beforeEach(async () => { t = await createTestApp(); const { cookie } = await provisionUser(t.app, "alice"); api = apiClient(t.app, cookie); const created = (await ( await api.post("/api/projects", { projectId: "alice-index", name: "test project" }) ).json()) as ProjectCreateResponse; projectId = created.project.projectId; }); afterEach(async () => { await t.cleanup(); }); async function configureModels(): Promise { const res = await api.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "anthropic", modelId: "claude-sonnet-4-6" }, models: [{ provider: "anthropic", modelId: "claude-sonnet-4-6", contextWindow: 128000 }], }); expect(res.status).toBe(200); } it("creating a Session with no default model configured → 400 no_default_model", async () => { // A newly created Project comes with a default model preset: first replace // the whole table to clear it (omitting defaultModel + the original default // absent from models = removes default_model), then verify the // no-default-model error path. const cleared = await api.put(`/api/projects/${projectId}/models`, { models: [{ provider: "custom", modelId: "m-no-default" }], }); expect(cleared.status).toBe(200); const res = await api.post(base(), {}); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("no_default_model"); }); it("creating a Session when the model has no usable credential → 400 model_credential_missing", async () => { // A model using the OpenAI protocol: the SDK requires a credential as soon as // the client is constructed. Clear the environment variable key so none is // available — the error must carry an **error code** (the frontend renders // localized text from the code, not by parsing the message text). const prev = process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY; try { await api.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "custom", modelId: "no-key-model" }, models: [{ provider: "custom", modelId: "no-key-model", clientType: "openai" }], }); const res = await api.post(base(), {}); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string; message: string } }; expect(body.error.code).toBe("model_credential_missing"); // The raw SDK message (littered with the env var name) must not leak. expect(body.error.message).not.toMatch(/OPENAI_API_KEY/); expect(body.error.message).toContain("no-key-model"); } finally { if (prev !== undefined) process.env.OPENAI_API_KEY = prev; } }); it("creating a Session: auto temp Workspace by default, allow-all default, shows in the list", async () => { await configureModels(); const res = await api.post(base(), {}); expect(res.status).toBe(201); const { session } = (await res.json()) as SessionCreateResponse; expect(session.sessionId).toMatch(/^session-\d{4}-/); expect(session.modelId).toBe("claude-sonnet-4-6"); expect(session.approvalMode).toBe("allow-all"); expect(session.status).toBe("idle"); expect(session.hasTrace).toBe(false); // The temporary Workspace lives inside this Agent's workspaces directory. expect(session.workspace).toContain( path.join(projectId, "agents", "default_agent", "workspaces"), ); const list = (await (await api.get(base())).json()) as SessionsResponse; expect(list.sessions.map((s) => s.sessionId)).toContain(session.sessionId); }); it("schedule-created Session: source derives from session_meta (registry), never from the DB row; user sessions carry none", async () => { await configureModels(); // The scheduler goes through SessionService.createSession directly (no HTTP route exposes source). const info = await t.deps.sessionService.createSession({ projectId, agentId: "default_agent", source: "schedule", }); expect(info.source).toBe("schedule"); // The index row stores no origin: session_meta is the single source of truth. const row = t.deps.sessionsRepo.findById(info.sessionId); expect(row && "source" in row).toBe(false); expect(t.deps.sessionSources.get(info.sessionId)).toBe("schedule"); // A user-created session (HTTP) has no source, and the list surfaces both accordingly. const res = await api.post(base(), {}); expect(res.status).toBe(201); const { session: plain } = (await res.json()) as SessionCreateResponse; const list = (await (await api.get(base())).json()) as SessionsResponse; expect(list.sessions.find((s) => s.sessionId === info.sessionId)?.source).toBe("schedule"); expect(list.sessions.find((s) => s.sessionId === plain.sessionId)?.source).toBeUndefined(); }); it("source survives a restart via the Trace head: an indexed row unknown to this process derives it lazily from session_meta", async () => { await configureModels(); // Simulate a Session created by a previous process: the index row exists, but the // in-process registry has never seen it — only its Trace's session_meta knows the origin. const sid = "session-2026-07-02-09-00-00-feedc0de"; t.deps.sessionsRepo.insert({ sessionId: sid, projectId, agentId: "default_agent", provider: "custom", modelId: "m-x", workspace: "/tmp/w-restart", approvalMode: "allow-all", title: null, createdAt: "2026-07-02T09:00:00.000Z", }); const meta: SessionMetaPayload = { session_id: sid, model_id: "m-x", provider: "custom", model_context_window: 1000, system_prompt: "", tools: [], agent_state: "/tmp/a", workspace: "/tmp/w-restart", source: "subagent", }; await writeTraceFile(t.root, projectId, "default_agent", "2026-07-02", sid, 1, [ sessionMeta(meta), userText("child work"), ]); const list = (await (await api.get(base())).json()) as SessionsResponse; expect(list.sessions.find((s) => s.sessionId === sid)?.source).toBe("subagent"); // The single-session endpoint derives it the same way (and the second read hits the registry). const single = (await (await api.get(`/api/sessions/${sid}`)).json()) as SessionResponse; expect(single.session.source).toBe("subagent"); }); it("adoption derives source from the Trace meta, narrowing junk values to user-created", async () => { await configureModels(); // Discovered (no index row) with a valid origin: adoption records it. const adopted = "session-2026-07-03-10-00-00-0badf00d"; const sourced: SessionMetaPayload = { session_id: adopted, model_id: "m-cli", provider: "custom", model_context_window: 1000, system_prompt: "", tools: [], agent_state: "/tmp/a", workspace: "/tmp/w-cli", source: "schedule", }; await writeTraceFile(t.root, projectId, "default_agent", "2026-07-03", adopted, 1, [ sessionMeta(sourced), userText("adopted"), ]); // Discovered with a junk source (untrusted on-disk data): narrowed to user-created. const junk = "session-2026-07-03-11-00-00-0badf00e"; const junkMeta = { ...sourced, session_id: junk, source: "weird-origin", } as unknown as SessionMetaPayload; await writeTraceFile(t.root, projectId, "default_agent", "2026-07-03", junk, 1, [ sessionMeta(junkMeta), userText("junk"), ]); const list = (await (await api.get(`${base()}?cli=1`)).json()) as SessionsResponse; expect(list.sessions.find((s) => s.sessionId === adopted)?.source).toBe("schedule"); expect(list.sessions.find((s) => s.sessionId === junk)?.source).toBeUndefined(); }); it("list paging: limit/offset slice the newest-first list; absent params keep the full list; invalid values 400", async () => { await configureModels(); // Three sessions with distinct createdAt ordering (insert directly for deterministic times). const mk = (n: number) => ({ sessionId: `session-2026-07-0${n}-08-00-00-aaaa000${n}`, projectId, agentId: "default_agent", provider: "custom", modelId: "m-page", workspace: `/tmp/w-${n}`, approvalMode: "allow-all" as const, title: null, createdAt: `2026-07-0${n}T08:00:00.000Z`, }); for (const n of [1, 2, 3]) t.deps.sessionsRepo.insert(mk(n)); const ids = async (qs: string) => { const res = await api.get(`${base()}${qs}`); expect(res.status).toBe(200); const body = (await res.json()) as SessionsResponse; return body.sessions.map((s) => s.sessionId); }; const all = await ids(""); expect(all).toEqual([mk(3).sessionId, mk(2).sessionId, mk(1).sessionId]); // newest first, unpaged expect(await ids("?limit=2")).toEqual(all.slice(0, 2)); expect(await ids("?limit=2&offset=2")).toEqual(all.slice(2)); expect(await ids("?limit=2&offset=9")).toEqual([]); // past the end: empty page, not an error // The sidebar's limit+1 trick: one extra row answers "has more" without an envelope change. expect((await ids("?limit=3")).length).toBe(3); for (const bad of ["?limit=0", "?limit=-1", "?limit=abc", "?limit=1001", "?offset=1"]) { expect((await api.get(`${base()}${bad}`)).status, bad).toBe(400); } expect((await api.get(`${base()}?limit=2&offset=-1`)).status).toBe(400); }); it("category filter: each sidebar bucket lists only its rows, paging applies within the category, counts return full totals", async () => { await configureModels(); // Two active user Sessions + one archived (HTTP), two schedule-created (service, one // then archived — archived must win over the origin), and one subagent Session whose // source only exists in its Trace head (cold-registry derivation during the walk). const mkUser = async () => ((await (await api.post(base(), {})).json()) as SessionCreateResponse).session.sessionId; const activeA = await mkUser(); const activeB = await mkUser(); const archivedC = await mkUser(); expect((await api.patch(`/api/sessions/${archivedC}`, { archived: true })).status).toBe(200); const mkSchedule = async () => ( await t.deps.sessionService.createSession({ projectId, agentId: "default_agent", source: "schedule", }) ).sessionId; const scheduleD = await mkSchedule(); const archivedScheduleF = await mkSchedule(); expect((await api.patch(`/api/sessions/${archivedScheduleF}`, { archived: true })).status).toBe( 200, ); const subagentE = "session-2026-07-02-09-30-00-cafe0001"; t.deps.sessionsRepo.insert({ sessionId: subagentE, projectId, agentId: "default_agent", provider: "custom", modelId: "m-x", workspace: "/tmp/w-sub", approvalMode: "allow-all", title: null, createdAt: "2026-07-02T09:30:00.000Z", }); await writeTraceFile(t.root, projectId, "default_agent", "2026-07-02", subagentE, 1, [ sessionMeta({ session_id: subagentE, model_id: "m-x", provider: "custom", model_context_window: 1000, system_prompt: "", tools: [], agent_state: "/tmp/a", workspace: "/tmp/w-sub", source: "subagent", }), userText("child work"), ]); const list = async (qs: string) => { const res = await api.get(`${base()}${qs}`); expect(res.status, qs).toBe(200); return (await res.json()) as SessionsResponse; }; const idSet = (body: SessionsResponse) => new Set(body.sessions.map((s) => s.sessionId)); expect(idSet(await list("?category=active"))).toEqual(new Set([activeA, activeB])); expect(idSet(await list("?category=schedule"))).toEqual(new Set([scheduleD])); expect(idSet(await list("?category=subagent"))).toEqual(new Set([subagentE])); expect(idSet(await list("?category=archived"))).toEqual( new Set([archivedC, archivedScheduleF]), ); // Paging applies within the category: the two archived rows page one at a time. const page1 = await list("?category=archived&limit=1&offset=0"); const page2 = await list("?category=archived&limit=1&offset=1"); expect(page1.sessions).toHaveLength(1); expect(page2.sessions).toHaveLength(1); expect(new Set([...idSet(page1), ...idSet(page2)])).toEqual( new Set([archivedC, archivedScheduleF]), ); expect((await list("?category=archived&limit=1&offset=2")).sessions).toEqual([]); // counts=1 returns totals over the whole list, not the returned page — with or without a filter. const counted = await list("?category=active&counts=1&limit=1"); expect(counted.sessions).toHaveLength(1); expect(counted.counts).toEqual({ active: 2, subagent: 1, schedule: 1, archived: 2 }); const full = await list("?counts=1"); expect(full.sessions).toHaveLength(6); expect(full.counts).toEqual({ active: 2, subagent: 1, schedule: 1, archived: 2 }); expect((await list("")).counts).toBeUndefined(); expect((await list("")).workspaceCounts).toBeUndefined(); // The per-Workspace breakdown accompanies the totals and sums back to them: the // subagent Session sits alone in its path; every other row lives in its own auto // temp directory. const byWorkspace = full.workspaceCounts!; expect(byWorkspace["/tmp/w-sub"]).toEqual({ active: 0, subagent: 1, schedule: 0, archived: 0, }); const summed = { active: 0, subagent: 0, schedule: 0, archived: 0 }; for (const ws of Object.values(byWorkspace)) { for (const key of Object.keys(summed) as (keyof typeof summed)[]) summed[key] += ws[key]; } expect(summed).toEqual(full.counts); // Junk values are rejected, never silently unfiltered. expect((await api.get(`${base()}?category=weird`)).status).toBe(400); expect((await api.get(`${base()}?counts=yes`)).status).toBe(400); }); it("half a model reference is 400: the missing half is never inferred", async () => { await configureModels(); // Only modelId: even though it names the one configured model, the provider is never // filled in for the caller — a reference is submitted as a pair or not at all. const onlyModel = await api.post(base(), { modelId: "claude-sonnet-4-6" }); expect(onlyModel.status).toBe(400); const onlyProvider = await api.post(base(), { provider: "anthropic" }); expect(onlyProvider.status).toBe(400); // The complete pair works, and so does omitting both (Project default). expect( (await api.post(base(), { provider: "anthropic", modelId: "claude-sonnet-4-6" })).status, ).toBe(201); expect((await api.post(base(), {})).status).toBe(201); }); it("an explicit Workspace only needs to exist; it may live outside the Project directory", async () => { await configureModels(); const inside = path.join(t.root, projectId, "my-workdir"); await fs.mkdir(inside, { recursive: true }); const ok = await api.post(base(), { workspace: inside }); expect(ok.status).toBe(201); const { session } = (await ok.json()) as SessionCreateResponse; expect(session.workspace).toBe(await fs.realpath(inside)); // An existing directory outside the Project directory is likewise allowed (reachability is left to file permissions). const outside = path.join(t.root, "not-a-project"); await fs.mkdir(outside, { recursive: true }); const okOutside = await api.post(base(), { workspace: outside }); expect(okOutside.status).toBe(201); // A nonexistent directory is still 400 (not auto-created). expect( (await api.post(base(), { workspace: path.join(t.root, projectId, "ghost") })).status, ).toBe(400); }); it("list union: Trace directory discovery finds unmanaged Sessions and backfills index rows", async () => { await configureModels(); const discovered = "session-2026-07-01-08-30-00-deadbeef"; const meta: SessionMetaPayload = { session_id: discovered, model_id: "cli-model", provider: "custom", model_context_window: 1000, system_prompt: "", tools: [], agent_state: "/tmp/a", workspace: "/tmp/cli-workspace", }; await writeTraceFile(t.root, projectId, "default_agent", "2026-07-01", discovered, 1, [ sessionMeta(meta), userText("cli session"), ]); // The default list is DB-only (web rows): an unmanaged CLI Trace is neither listed // nor adopted by it (#139 — no Trace-directory scanning on the default path). const webOnly = (await (await api.get(base())).json()) as SessionsResponse; expect(webOnly.sessions.find((s) => s.sessionId === discovered)).toBeUndefined(); expect((await api.get(`/api/sessions/${discovered}`)).status).toBe(404); const list = (await (await api.get(`${base()}?cli=1`)).json()) as SessionsResponse; const found = list.sessions.find((s) => s.sessionId === discovered); expect(found).toBeDefined(); expect(found!.modelId).toBe("cli-model"); expect(found!.workspace).toBe("/tmp/cli-workspace"); expect(found!.approvalMode).toBe("allow-all"); expect(found!.hasTrace).toBe(true); expect(found!.createdAt).toBe(sessionIdCreatedAt(discovered)); // Adopted as client "cli": the default list still excludes it afterwards, and counts // follow the same filter… const after = (await (await api.get(`${base()}?counts=1`)).json()) as SessionsResponse; expect(after.sessions.find((s) => s.sessionId === discovered)).toBeUndefined(); expect(after.counts!.active).toBe(0); // …but the adopted row makes the Session individually reachable (deep links work). const single = await api.get(`/api/sessions/${discovered}`); expect(single.status).toBe(200); }); it("legacy rows without a client marker stay visible by default (grandfathered as web)", async () => { const legacy = "session-2026-07-02-09-00-00-0abc0001"; t.deps.sessionsRepo.insert({ sessionId: legacy, projectId, agentId: "default_agent", provider: "custom", modelId: "m-legacy", workspace: "/tmp/w-legacy", approvalMode: "allow-all", title: null, createdAt: "2026-07-02T09:00:00.000Z", }); const list = (await (await api.get(base())).json()) as SessionsResponse; expect(list.sessions.find((s) => s.sessionId === legacy)).toBeDefined(); }); it("DELETE Session: clears the index row and every Trace shard; the list doesn't resurrect it; re-delete 404", async () => { await configureModels(); const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse; const sessionId = session.sessionId; // Create a Trace spanning multiple dated shards: deletion must clear all of // them, or the listing's directory discovery would resurrect the session. const meta: SessionMetaPayload = { session_id: sessionId, model_id: "anthropic/claude-sonnet-4-6", provider: "custom", model_context_window: 1000, system_prompt: "", tools: [], agent_state: "/tmp/a", workspace: session.workspace, }; const f1 = await writeTraceFile( t.root, projectId, "default_agent", "2026-07-01", sessionId, 1, [sessionMeta(meta), userText("round one")], ); const f2 = await writeTraceFile( t.root, projectId, "default_agent", "2026-07-02", sessionId, 2, [sessionMeta(meta), userText("round two")], ); const del = await api.delete(`/api/sessions/${sessionId}`); expect(del.status).toBe(204); await expect(fs.stat(f1)).rejects.toThrow(); await expect(fs.stat(f2)).rejects.toThrow(); const list = (await (await api.get(base())).json()) as SessionsResponse; expect(list.sessions.map((s) => s.sessionId)).not.toContain(sessionId); expect((await api.delete(`/api/sessions/${sessionId}`)).status).toBe(404); expect((await api.get(`/api/sessions/${sessionId}`)).status).toBe(404); }); it("DELETE Session: the Workspace directory is not removed (user-supplied directories must survive)", async () => { await configureModels(); const inside = path.join(t.root, projectId, "keep-me"); await fs.mkdir(inside, { recursive: true }); const { session } = (await ( await api.post(base(), { workspace: inside }) ).json()) as SessionCreateResponse; expect((await api.delete(`/api/sessions/${session.sessionId}`)).status).toBe(204); expect((await fs.stat(inside)).isDirectory()).toBe(true); }); it("the list is sorted by createdAt descending", async () => { await configureModels(); const older = "session-2020-01-01-00-00-00-00000001"; await writeTraceFile(t.root, projectId, "default_agent", "2020-01-01", older, 1, [ sessionMeta({ session_id: older, model_id: "m", provider: "custom", model_context_window: 1, system_prompt: "", tools: [], agent_state: "/a", workspace: "/w", }), ]); const created = (await (await api.post(base(), {})).json()) as SessionCreateResponse; const list = (await (await api.get(`${base()}?cli=1`)).json()) as SessionsResponse; expect(list.sessions[0]!.sessionId).toBe(created.session.sessionId); expect(list.sessions[list.sessions.length - 1]!.sessionId).toBe(older); }); it("PATCH approval mode persists and reads back", async () => { await configureModels(); const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse; // Change from the default allow-all to a different mode, to confirm it's actually persisted. const patched = await api.patch(`/api/sessions/${session.sessionId}`, { approvalMode: "always-ask", }); expect(patched.status).toBe(200); const got = (await ( await api.get(`/api/sessions/${session.sessionId}`) ).json()) as SessionResponse; expect(got.session.approvalMode).toBe("always-ask"); // An invalid mode returns 400. expect( (await api.patch(`/api/sessions/${session.sessionId}`, { approvalMode: "sometimes" })).status, ).toBe(400); }); it("insertOrIgnore is idempotent: concurrent first discovery of one Session doesn't throw on the UNIQUE constraint", async () => { const row = { sessionId: "session-2026-07-02-00-00-00-11223344", projectId, agentId: "default_agent", modelId: "cli-model", provider: "custom", workspace: "/tmp/w", approvalMode: "always-ask" as const, title: null, createdAt: new Date().toISOString(), }; t.deps.sessionsRepo.insertOrIgnore(row); // A second insert with different fields for the same id: silently ignored, no throw, first-inserted value is kept. expect(() => t.deps.sessionsRepo.insertOrIgnore({ ...row, modelId: "other-model" }), ).not.toThrow(); expect(t.deps.sessionsRepo.findById(row.sessionId)!.modelId).toBe("cli-model"); }); it("sessionIdCreatedAt: invalid formats return null", () => { expect(sessionIdCreatedAt("session-2026-07-01-08-30-00-deadbeef")).toBe( new Date(2026, 6, 1, 8, 30, 0).toISOString(), ); expect(sessionIdCreatedAt("not-a-session")).toBeNull(); }); it("single-session GET exposes tracePath (the LATEST shard); absent without a trace; list rows omit it", async () => { await configureModels(); const res = await api.post(base(), {}); const { session } = (await res.json()) as SessionCreateResponse; // No trace yet: no tracePath. const before = (await ( await api.get(`/api/sessions/${session.sessionId}`) ).json()) as SessionResponse; expect(before.session.tracePath).toBeUndefined(); const meta: SessionMetaPayload = { session_id: session.sessionId, model_id: session.modelId, provider: session.provider, model_context_window: 128000, system_prompt: "", tools: [], agent_state: "/tmp/a", workspace: session.workspace, }; await writeTraceFile(t.root, projectId, "default_agent", "2026-07-02", session.sessionId, 1, [ sessionMeta(meta), userText("a"), ]); await writeTraceFile(t.root, projectId, "default_agent", "2026-07-03", session.sessionId, 2, [ sessionMeta(meta), userText("b"), ]); const after = (await ( await api.get(`/api/sessions/${session.sessionId}`) ).json()) as SessionResponse; // The /model switch block hands this to the model: it must point at the latest shard. expect(after.session.tracePath?.endsWith(`${session.sessionId}_002.jsonl`)).toBe(true); // List rows omit it (locating it would cost a directory walk per row). const list = (await (await api.get(base())).json()) as SessionsResponse; expect(list.sessions.find((s) => s.sessionId === session.sessionId)?.tracePath).toBeUndefined(); }); it("rejects an invalid per-task thinkingLevel with 400 (five names only)", async () => { await configureModels(); const res = await api.post(base(), {}); const { session } = (await res.json()) as SessionCreateResponse; const bad = await api.post(`/api/sessions/${session.sessionId}/tasks`, { input: [{ type: "text", text: "hi" }], thinkingLevel: "ultra", }); expect(bad.status).toBe(400); }); it("rejects a malformed goal.budget with 400", async () => { await configureModels(); const res = await api.post(base(), {}); const { session } = (await res.json()) as SessionCreateResponse; for (const budget of ["500k", 0, -2, 1.5]) { const bad = await api.post(`/api/sessions/${session.sessionId}/tasks`, { input: [{ type: "text", text: "objective" }], goal: { budget }, }); expect(bad.status).toBe(400); } // An image alone states no goal: the objective is re-injected as text every round. const imageOnly = await api.post(`/api/sessions/${session.sessionId}/tasks`, { input: [{ type: "image_url", imageUrl: "data:image/png;base64,aGk=" }], goal: {}, }); expect(imageOnly.status).toBe(400); // With text alongside them the images are fine: they reach the manager, and core folds // them into the objective as path lines from there. startGoal stands in for the run so // the assertion is about validation alone — a real goal loop would still be settling // after the test closed its database. const started: OmniMessage[][] = []; t.deps.manager.startGoal = async (sessionId, args) => { started.push(args.input); return { sessionId }; }; const withText = await api.post(`/api/sessions/${session.sessionId}/tasks`, { input: [ { type: "text", text: "objective" }, { type: "image_url", imageUrl: "data:image/png;base64,aGk=" }, ], goal: {}, }); expect(withText.status).toBe(202); expect(started[0]?.map((m) => (m.payload as { type: string }).type)).toEqual([ "text", "image_url", ]); // A file attachment is the one input a goal cannot take, images notwithstanding: nothing // folds it into the objective every round re-injects, so it is refused before any upload // is written to disk (startGoal is never reached). const withFile = await api.post(`/api/sessions/${session.sessionId}/tasks`, { input: [ { type: "text", text: "objective" }, { type: "file", fileName: "report.pdf", dataUrl: `data:application/pdf;base64,${Buffer.from("PDF-BYTES").toString("base64")}`, }, ], goal: {}, }); expect(withFile.status).toBe(400); expect(started).toHaveLength(1); }); });