import { describe, expect, it } from "vitest"; import { createSession, runTurn, sessionContextTokens } from "./agent"; import { countTextTokens } from "./context"; import type { AgentUI, LLMProvider, PermissionPolicy, StreamEvent, Tool } from "./types"; const silentUI: AgentUI = { onThinking() {}, onText() {}, onTextEnd() {}, onToolCall() {}, onToolResult() {}, confirm: async () => true, }; /** Provider factice : 1er appel → demande d'outil, 2e appel → réponse texte. */ function fakeProvider(toolName = "echo"): LLMProvider { let call = 0; return { id: "fake", model: "fake", capabilities: { tools: true }, async *chat(): AsyncIterable { call++; if (call === 1) { yield { type: "tool_use", id: "t1", name: toolName, input: { value: "salut" } }; yield { type: "done", stopReason: "tool_use" }; } else { yield { type: "text_delta", text: "terminé" }; yield { type: "done", stopReason: "end_turn" }; } }, }; } describe("runTurn", () => { it("exécute l'outil demandé puis renvoie la réponse finale", async () => { const echo: Tool = { spec: { name: "echo", description: "echo", inputSchema: { type: "object" } }, permission: "auto", execute: async (input) => String(input.value), }; const session = createSession({ provider: fakeProvider(), init: { systemPrompt: "test", tools: [echo] }, ui: silentUI, cwd: process.cwd(), }); await runTurn(session, "bonjour"); // user, assistant(tool_use), user(tool_result), assistant(texte) expect(session.messages).toHaveLength(4); expect(session.messages[2]?.content[0]).toMatchObject({ type: "tool_result", content: "salut", }); }); }); describe("politique de permissions", () => { const makeTool = (ran: string[]): Tool => ({ spec: { name: "danger", description: "d", inputSchema: { type: "object" } }, permission: "ask", execute: async () => { ran.push("x"); return "ok"; }, }); const policy = (decision: "allow" | "ask" | "deny"): PermissionPolicy => ({ check: () => ({ decision, reason: "règle de test" }), }); async function run(decision: "allow" | "ask" | "deny", answer: boolean) { const ran: string[] = []; const confirms: string[] = []; const session = createSession({ provider: fakeProvider("danger"), init: { systemPrompt: "t", tools: [makeTool(ran)], permissions: policy(decision) }, ui: { ...silentUI, confirm: async (m) => (confirms.push(m), answer) }, cwd: process.cwd(), }); await runTurn(session, "go"); return { ran, confirms, session }; } it("allow : exécute sans confirmation", async () => { const { ran, confirms } = await run("allow", false); expect(ran).toHaveLength(1); expect(confirms).toHaveLength(0); }); it("deny : refuse sans demander", async () => { const { ran, confirms, session } = await run("deny", true); expect(ran).toHaveLength(0); expect(confirms).toHaveLength(0); expect(session.messages[2]?.content[0]).toMatchObject({ type: "tool_result", isError: true }); }); it("ask : demande confirmation même pour un outil automatique", async () => { const { ran, confirms } = await run("ask", true); expect(confirms).toHaveLength(1); expect(ran).toHaveLength(1); }); }); describe("contexte en attente", () => { it("est joint à la prochaine demande puis vidé", async () => { const session = createSession({ provider: fakeProvider("echo"), init: { systemPrompt: "t", tools: [{ spec: { name: "echo", description: "e", inputSchema: { type: "object" } }, permission: "auto", execute: async () => "ok" }], }, ui: silentUI, cwd: process.cwd(), }); session.pendingContext.push("CONTEXTE-A", "CONTEXTE-B"); await runTurn(session, "ma demande"); const first = session.messages[0]?.content[0]; expect(first).toMatchObject({ type: "text", text: "CONTEXTE-A\n\nCONTEXTE-B\n\nma demande" }); expect(session.pendingContext).toEqual([]); }); }); describe("taille du contexte", () => { const echo: Tool = { spec: { name: "echo", description: "echo", inputSchema: { type: "object" } }, permission: "auto", execute: async () => "résultat de l'outil", }; /** Provider qui répond par un texte, après un appel d'outil au 1er tour ; il renvoie des consommations absurdes (ignorées). */ function chatty(): LLMProvider { let n = 0; return { id: "fake", model: "fake", capabilities: { tools: true }, async *chat(req): AsyncIterable { n++; const last = req.messages.at(-1); const afterTool = last?.content.some((b) => b.type === "tool_result"); if (!afterTool && n % 2 === 1) { yield { type: "tool_use", id: `t${n}`, name: "echo", input: { n } }; yield { type: "usage", inputTokens: 7, outputTokens: 1 }; yield { type: "done", stopReason: "tool_use" }; } else { yield { type: "text_delta", text: `réponse numéro ${n} `.repeat(5) }; yield { type: "usage", inputTokens: 999_999 - n * 10_000, outputTokens: 3 }; yield { type: "done", stopReason: "end_turn" }; } }, }; } it("ne fait que croître quand la conversation s'enrichit (aucune variation due au serveur)", async () => { const reported: number[] = []; const session = createSession({ provider: chatty(), init: { systemPrompt: "prompt système", tools: [echo] }, ui: { ...silentUI, onContextUsage: (n) => reported.push(n) }, cwd: process.cwd(), }); const beforePrompts: number[] = [sessionContextTokens(session)]; for (const prompt of ["premier message", "deuxième message un peu plus long", "troisième", "quatrième"]) { await runTurn(session, prompt); beforePrompts.push(sessionContextTokens(session)); // valeur affichée avant la saisie suivante } for (let i = 1; i < beforePrompts.length; i++) expect(beforePrompts[i]!).toBeGreaterThan(beforePrompts[i - 1]!); for (let i = 1; i < reported.length; i++) expect(reported[i]!).toBeGreaterThan(reported[i - 1]!); expect(reported.at(-1)).toBe(beforePrompts.at(-1)); }); it("la valeur envoyée à l'UI après chaque réponse est le comptage local de la session", async () => { const seen: number[] = []; const session = createSession({ provider: chatty(), init: { systemPrompt: "x".repeat(4000), tools: [echo] }, ui: { ...silentUI, onContextUsage: (n) => seen.push(n) }, cwd: process.cwd(), }); await runTurn(session, "go"); expect(seen).toHaveLength(2); // une mise à jour par réponse du LLM (appel d'outil, puis réponse finale) expect(seen[0]).toBeGreaterThan(0); expect(seen[1]).toBeGreaterThan(seen[0]!); expect(seen[1]).toBe(sessionContextTokens(session)); expect(seen[1]).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée }); it("le contexte en attente (commandes « ! ») est compté dans la taille de la session", () => { const session = createSession({ provider: chatty(), init: { systemPrompt: "t", tools: [] }, ui: silentUI, cwd: "." }); const base = sessionContextTokens(session); session.pendingContext.push("y ".repeat(2000)); expect(sessionContextTokens(session)).toBe(base + countTextTokens("y ".repeat(2000))); session.pendingContext.length = 0; expect(sessionContextTokens(session)).toBe(base); }); }); describe("réponse coupée par la limite de tokens de sortie", () => { it("avertit l'utilisateur quand le serveur signale max_tokens", async () => { const results: string[] = []; const provider: LLMProvider = { id: "f", model: "f", capabilities: { tools: true }, async *chat(): AsyncIterable { yield { type: "text_delta", text: "début de réponse" }; yield { type: "done", stopReason: "max_tokens" }; }, }; const session = createSession({ provider, init: { systemPrompt: "t", tools: [] }, ui: { ...silentUI, onToolResult: (name, out, isError) => void results.push(`${name}|${isError}|${out}`) }, cwd: ".", }); await runTurn(session, "écris un long fichier"); expect(results).toHaveLength(1); expect(results[0]).toContain("agent|true|Réponse"); expect(results[0]).toContain("MaxOutputTokens"); }); }); describe("appel d'outil « réparé » par le serveur après une coupure", () => { const writeTool: Tool = { spec: { name: "Write", description: "w", inputSchema: { type: "object" } }, permission: "auto", execute: async () => "ok", }; function run(outputTokens: number) { const results: string[] = []; let n = 0; const provider: LLMProvider = { id: "f", model: "f", capabilities: { tools: true }, async *chat(): AsyncIterable { n++; if (n === 1) { yield { type: "tool_use", id: "t", name: "Write", input: { path: "a.md" } }; yield { type: "usage", inputTokens: 47_000, outputTokens }; yield { type: "done", stopReason: "tool_use" }; } else { yield { type: "text_delta", text: "fin" }; yield { type: "done", stopReason: "end_turn" }; } }, }; const session = createSession({ provider, init: { systemPrompt: "t", tools: [writeTool] }, ui: { ...silentUI, onToolResult: (name, out) => void results.push(`${name}|${out}`) }, cwd: ".", }); return runTurn(session, "écris").then(() => results); } it("sortie de 4096 tokens pile avec un appel d'outil : avertissement", async () => { const r = await run(4096); expect(r.some((x) => x.startsWith("agent|Réponse probablement coupée"))).toBe(true); }); it("sortie ordinaire : pas d'avertissement", async () => { const r = await run(137); expect(r.some((x) => x.startsWith("agent|"))).toBe(false); }); });