/** * 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("restart_cell_agent_runtime forwards the exact cell presentation", async () => { const response = { restartId: "restart-1", status: "scheduled", agentId: "agent-2", nodeId: "node-3", kind: "structured", presentation: "customCli", layoutId: "layout-4", }; invoke.mockResolvedValueOnce(response); await expect( new TauriAgentGateway().restartCellAgentRuntime({ projectId: "proj-1", nodeId: "node-3", agentId: "agent-2", presentation: "customCli", layoutId: "layout-4", }), ).resolves.toEqual(response); expect(invoke).toHaveBeenCalledWith("restart_cell_agent_runtime", { request: { projectId: "proj-1", nodeId: "node-3", agentId: "agent-2", presentation: "customCli", layoutId: "layout-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 () => { const response = { sessionId: "chat-session-1", outcome: "cancelled" as const, }; invoke.mockResolvedValueOnce(response); await expect( new TauriAgentGateway().cancelAgentChat("chat-session-1"), ).resolves.toEqual(response); 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", transcriptUnavailableReason: "missingTranscript", }); 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", transcriptUnavailableReason: "missingTranscript", }); }); 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(); }); });