- Exécution de commandes shell inline avec `!commande` ou `!`commande args``
- Support dans les prompts directs et les fichiers .claude/commands/**/*.md
- Exécution parallèle de multiples commandes
- Gestion robuste des erreurs et timeouts
- Intégration avec les variables d'environnement de settings.json
Nouveaux fichiers:
- packages/cli/src/user-shell.ts
- packages/core/src/shell.ts et shell.test.ts
- packages/ext-claudecode/src/tools/todowrite.test.ts et webfetch.test.ts
- packages/ui/src/banner.tsx, pixels.ts et pixels.test.ts
Tests: ✅ Tous les tests passent
Breaking Changes: Mineur - SlashCommand.prompt() async
124 lines
4 KiB
TypeScript
124 lines
4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { createSession, runTurn } from "./agent";
|
|
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([]);
|
|
});
|
|
});
|