MagicCode/packages/core/src/agent.test.ts
Arnaud 9f3badd79d Update .gitignore and add SPECS_TECH.md with project modifications
- Add HOW_To_Agents.md, generated_page.md, llm_avis.md to .gitignore
- Add SPECS_TECH.md technical specifications
- Add test files for bash and write tools
- Update various core, agent, provider and UI components
2026-10-20 01:45:23 +02:00

269 lines
9.9 KiB
TypeScript

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<StreamEvent> {
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<StreamEvent> {
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<StreamEvent> {
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<StreamEvent> {
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);
});
});