Files
IdeA/frontend/src/adapters/agent.test.ts
Blomios 853d8229f2 feat(cli): menu contextuel d'autocomplétion des slash-commands dans le composer — #163 (QA verte)
À la saisie d'un « / » en début de draft (sans espace), ouverture d'un menu
contextuel listant les commandes matchant le préfixe, via le contrat unifié
listSlashCommands — aucune liste codée en dur côté UI. Rafinement incrémental
des suggestions à la frappe, navigation clavier + sélection, exécution par
executeSlashCommand.

- adapters/agent: listSlashCommands(prefix?) + executeSlashCommand (invoke Tauri).
- domain + ports: types SlashCommand / ExecuteSlashCommandResult.
- mock: données de test.
- CustomAgentChatView: détection de préfixe (/ sans espace), fetch avec séquence
  anti-retard, gestion ouverture/active index, navigation clavier, fermeture
  quand le préfixe cesse d'être éligible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 15:02:40 +02:00

329 lines
9.9 KiB
TypeScript

/**
* Contract tests for `TauriAgentGateway`: they assert the exact `invoke()`
* payload shape, which the mock gateway (used by feature tests) does NOT
* exercise. This guards the class of "works in mock, broken in the real app"
* bugs — e.g. forgetting to nest `projectId` inside the command's `request` DTO.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const invoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invoke(...args),
Channel: class {
onmessage: ((c: number[]) => void) | null = null;
},
}));
import { TauriAgentGateway } from "./agent";
describe("TauriAgentGateway invoke payloads", () => {
beforeEach(() => invoke.mockReset().mockResolvedValue({}));
it("create_agent nests projectId inside the request DTO", async () => {
await new TauriAgentGateway().createAgent("proj-1", {
name: "Backend",
profileId: "prof-9",
});
expect(invoke).toHaveBeenCalledWith("create_agent", {
request: {
projectId: "proj-1",
name: "Backend",
profileId: "prof-9",
initialContent: null,
},
});
});
it("update_agent_context wraps fields in the request DTO", async () => {
await new TauriAgentGateway().updateContext("proj-1", "agent-2", "# ctx");
expect(invoke).toHaveBeenCalledWith("update_agent_context", {
request: { projectId: "proj-1", agentId: "agent-2", content: "# ctx" },
});
});
it("list_agents / read / delete pass top-level args and unwrap read context DTO", async () => {
const gw = new TauriAgentGateway();
await gw.listAgents("p");
expect(invoke).toHaveBeenCalledWith("list_agents", { projectId: "p" });
invoke.mockResolvedValueOnce({ content: "# context" });
await expect(gw.readContext("p", "a")).resolves.toBe("# context");
expect(invoke).toHaveBeenCalledWith("read_agent_context", {
projectId: "p",
agentId: "a",
});
await gw.deleteAgent("p", "a");
expect(invoke).toHaveBeenCalledWith("delete_agent", {
projectId: "p",
agentId: "a",
});
});
it("attach_live_agent wraps project, agent and target node in the request DTO", async () => {
invoke.mockResolvedValueOnce({
agentId: "agent-2",
nodeId: "node-3",
sessionId: "session-4",
kind: "pty",
});
const out = await new TauriAgentGateway().attachLiveAgent(
"proj-1",
"agent-2",
"node-3",
);
expect(invoke).toHaveBeenCalledWith("attach_live_agent", {
request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" },
});
expect(out.sessionId).toBe("session-4");
});
it("stop_live_agent wraps project and agent in the request DTO", async () => {
invoke.mockResolvedValueOnce({
agentId: "agent-2",
sessionId: "session-4",
kind: "pty",
});
const out = await new TauriAgentGateway().stopLiveAgent("proj-1", "agent-2");
expect(invoke).toHaveBeenCalledWith("stop_live_agent", {
request: { projectId: "proj-1", agentId: "agent-2" },
});
expect(out.sessionId).toBe("session-4");
});
it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => {
invoke.mockResolvedValueOnce({ agent: { id: "agent-2" } });
await new TauriAgentGateway().changeAgentProfile(
"proj-1",
"agent-2",
"prof-9",
30,
120,
);
expect(invoke).toHaveBeenCalledWith("change_agent_profile", {
request: {
projectId: "proj-1",
agentId: "agent-2",
profileId: "prof-9",
rows: 30,
cols: 120,
},
});
});
it("change_agent_profile returns the mutated agent and the relaunched session", async () => {
const response = {
agent: { id: "agent-2", profileId: "prof-9" },
relaunchedSession: { sessionId: "sess-7", cwd: "/p", rows: 30, cols: 120 },
};
invoke.mockResolvedValueOnce(response);
const out = await new TauriAgentGateway().changeAgentProfile(
"proj-1",
"agent-2",
"prof-9",
30,
120,
);
expect(out.agent.profileId).toBe("prof-9");
expect(out.relaunchedSession?.sessionId).toBe("sess-7");
});
it("change_agent_profile leaves relaunchedSession undefined when the backend omits it", async () => {
invoke.mockResolvedValueOnce({ agent: { id: "agent-2" } });
const out = await new TauriAgentGateway().changeAgentProfile(
"proj-1",
"agent-2",
"prof-9",
30,
120,
);
expect(out.relaunchedSession).toBeUndefined();
});
it("update_agent_effort wraps the nullable effort override in the request DTO", async () => {
invoke.mockResolvedValueOnce({ id: "agent-2", effort: { kind: "preset", value: "high" } });
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", {
kind: "preset",
value: "high",
});
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
request: {
projectId: "proj-1",
agentId: "agent-2",
effort: { kind: "preset", value: "high" },
},
});
invoke.mockClear().mockResolvedValueOnce({ id: "agent-2" });
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", null);
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
request: { projectId: "proj-1", agentId: "agent-2", effort: null },
});
});
it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => {
await new TauriAgentGateway().cancelAgentChat("chat-session-1");
expect(invoke).toHaveBeenCalledWith("cancel_agent_chat", {
sessionId: "chat-session-1",
});
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
});
it("sendAgentChat forwards structured clipboard attachments to agent_send", async () => {
await new TauriAgentGateway().sendAgentChat(
"chat-session-1",
"",
vi.fn(),
{
attachments: [
{
filename: "clipboard.png",
contentBase64: "AQID",
mime: "image/png",
sourceKind: "clipboard",
},
],
},
);
expect(invoke).toHaveBeenCalledWith("agent_send", {
sessionId: "chat-session-1",
prompt: "",
attachments: [
{
filename: "clipboard.png",
contentBase64: "AQID",
mime: "image/png",
sourceKind: "clipboard",
},
],
onReply: expect.anything(),
});
});
it("listSlashCommands forwards the unified slash-command filter request and unwraps commands", async () => {
invoke.mockResolvedValueOnce({
commands: [
{
name: "/clean",
shortDescription: "Clean current conversation",
requiresConfirmation: false,
availability: { status: "available" },
source: "native",
native: "clean",
},
],
});
const out = await new TauriAgentGateway().listSlashCommands("/c");
expect(invoke).toHaveBeenCalledWith("list_slash_commands", {
request: { prefix: "/c" },
});
expect(out).toHaveLength(1);
expect(out[0].name).toBe("/clean");
});
it("executeSlashCommand wraps the selected command and current session id", async () => {
invoke.mockResolvedValueOnce({
command: {
name: "/clean",
shortDescription: "Clean current conversation",
requiresConfirmation: false,
availability: { status: "available" },
source: "native",
native: "clean",
},
effect: {
kind: "cleanConversation",
sessionId: "chat-session-1",
clearedChunks: 2,
},
});
const out = await new TauriAgentGateway().executeSlashCommand("/clean", {
sessionId: "chat-session-1",
});
expect(invoke).toHaveBeenCalledWith("execute_slash_command", {
request: { name: "/clean", sessionId: "chat-session-1" },
});
expect(out.effect.kind).toBe("cleanConversation");
});
it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => {
invoke.mockResolvedValueOnce({
sessionId: "chat-session-1",
cwd: "/repo",
rows: 24,
cols: 80,
cellKind: "chat",
assignedConversationId: "conversation-1",
});
const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", {
cwd: "/repo",
rows: 24,
cols: 80,
conversationId: "conversation-0",
nodeId: "node-3",
});
expect(invoke).toHaveBeenCalledWith("launch_agent", {
request: {
projectId: "proj-1",
agentId: "agent-2",
rows: 24,
cols: 80,
cellKind: "chat",
conversationId: "conversation-0",
nodeId: "node-3",
},
onOutput: expect.anything(),
});
expect(out).toEqual({
sessionId: "chat-session-1",
cellKind: "chat",
assignedConversationId: "conversation-1",
});
});
it("launchAgentChat rejects a PTY-routed launch before returning a fake structured session", async () => {
const log = vi.spyOn(console, "error").mockImplementation(() => {});
invoke.mockResolvedValueOnce({
sessionId: "pty-session-1",
cwd: "/repo",
rows: 24,
cols: 80,
cellKind: "pty",
});
await expect(
new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", {
cwd: "/repo",
rows: 24,
cols: 80,
nodeId: "node-3",
}),
).rejects.toEqual({
code: "STRUCTURED_ROUTED_TO_PTY",
message:
"custom CLI launch for agent agent-2 in project proj-1 returned cellKind=pty; expected chat. sessionId=pty-session-1; nodeId=node-3",
});
expect(log).toHaveBeenCalledWith(
"[ticket149] launchAgentChat:routed-to-non-chat",
expect.objectContaining({
projectId: "proj-1",
agentId: "agent-2",
response: expect.objectContaining({
sessionId: "pty-session-1",
cellKind: "pty",
}),
}),
);
log.mockRestore();
});
});