- #310: horodatage de tous les messages et événements de conversation visibles — composant partagé EntryTimestamp, timestamps propagés dans le transcript chat, les annonces inter-agent et le viewer de conversation, normalisation côté domain/adapters (conversation_log, paginate). - #311: stop d'agent robuste pour PTY non monté, sessions headless et CLI custom — lifecycle/orchestrator durcis, ponts chat app-tauri/web-server alignés, arrêt fiable sans session orpheline. - #312: préférence globale d'interface terminal par défaut — nouveau store settings (domain/application/infrastructure + tests), DTO et commandes backend, adapter frontend userSettings, panneau TerminalSettings dans les paramètres globaux. - #313: Paramètres du projet — contexte de projet éditable et couleur de thème par projet (ProjectSettingsPanel + projectTheme), teinte des onglets projet, persistance via DTO projet. QA verte (GO QA sur feature/batch-310-311-312-313-chat-stop-prefs-theme) — vérifications Git: git diff --cached --check OK; staging borné à crates/ et frontend/ (74 fichiers, +2105/-531), commit unique de lot conforme à la convention des lots (précédents batch-151/158/159, batch-210/211/212) car les fichiers partagés (dto.rs, commands.rs, CustomAgentChatView) entrelacent les 4 tickets. fichiers .ideai/* (agents/test.md, chat-drafts.json, system-permissions.json, tickets/counter.json, tickets/index.json, tickets/308-313/), attachments/ et scripts/ hors périmètre et préservés en worktree ; aucun push. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
397 lines
12 KiB
TypeScript
397 lines
12 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",
|
|
stopped: [{
|
|
sessionId: "session-4",
|
|
nodeId: "node-3",
|
|
kind: "pty",
|
|
runtimeState: "stopped",
|
|
}],
|
|
alreadyStopped: false,
|
|
});
|
|
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.stopped[0]?.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("update_agent_contactable wraps the dedicated boolean in the request DTO", async () => {
|
|
invoke.mockResolvedValueOnce({ id: "agent-2", contactable: true });
|
|
|
|
await new TauriAgentGateway().updateAgentContactable(
|
|
"proj-1",
|
|
"agent-2",
|
|
true,
|
|
);
|
|
|
|
expect(invoke).toHaveBeenCalledWith("update_agent_contactable", {
|
|
request: { projectId: "proj-1", agentId: "agent-2", contactable: true },
|
|
});
|
|
});
|
|
|
|
it("update_project_orchestrator wraps the project and agent in the request DTO", async () => {
|
|
invoke.mockResolvedValueOnce({ orchestratorAgentId: "agent-2" });
|
|
|
|
await expect(
|
|
new TauriAgentGateway().updateProjectOrchestrator("proj-1", "agent-2"),
|
|
).resolves.toEqual({ orchestratorAgentId: "agent-2" });
|
|
|
|
expect(invoke).toHaveBeenCalledWith("update_project_orchestrator", {
|
|
request: { projectId: "proj-1", agentId: "agent-2" },
|
|
});
|
|
});
|
|
|
|
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("detachAgentChat invokes detach_agent_chat without closing the session", async () => {
|
|
await new TauriAgentGateway().detachAgentChat("chat-session-1");
|
|
|
|
expect(invoke).toHaveBeenCalledWith("detach_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", arguments: [] },
|
|
});
|
|
expect(out.effect.kind).toBe("cleanConversation");
|
|
});
|
|
|
|
it("wraps chat draft persistence commands with the project/layout/node scope", async () => {
|
|
const gateway = new TauriAgentGateway();
|
|
const scope = {
|
|
projectId: "proj-1",
|
|
layoutId: "layout-1",
|
|
nodeId: "node-1",
|
|
};
|
|
invoke.mockResolvedValueOnce({ text: "hello", updatedAtMs: 42 });
|
|
|
|
await expect(gateway.readChatDraft(scope)).resolves.toEqual({
|
|
text: "hello",
|
|
updatedAtMs: 42,
|
|
});
|
|
await gateway.saveChatDraft(scope, "edited");
|
|
await gateway.clearChatDraft(scope);
|
|
|
|
expect(invoke).toHaveBeenNthCalledWith(1, "read_chat_draft", {
|
|
request: scope,
|
|
});
|
|
expect(invoke).toHaveBeenNthCalledWith(2, "save_chat_draft", {
|
|
request: { ...scope, text: "edited" },
|
|
});
|
|
expect(invoke).toHaveBeenNthCalledWith(3, "clear_chat_draft", {
|
|
request: scope,
|
|
});
|
|
});
|
|
|
|
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",
|
|
freshStart: false,
|
|
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();
|
|
});
|
|
});
|