diff --git a/apps/server/test/self-improvement-prompt.test.ts b/apps/server/test/self-improvement-prompt.test.ts new file mode 100644 index 00000000..ac985200 --- /dev/null +++ b/apps/server/test/self-improvement-prompt.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { buildSelfImprovementGuidance } from "../src/shared/self-improvement-prompt.js"; + +/** + * This footer is the instruction an agent follows to rewrite its own saved + * launch prompt, so the tool name and the identifying arguments in it are + * load-bearing: name the wrong tool or the wrong key and a self-improving run + * either does nothing or edits somebody else's configuration. + * + * Only the job branch is covered here. The template branch already reaches + * three suites through its caller (template-launch-prompt, templates/service, + * mcp-handlers), while jobs/service.ts assembles its prompt in a module-private + * function, so nothing else in the repo asserts the update_job instruction. + */ +describe("buildSelfImprovementGuidance", () => { + it("tells a job agent to call update_job with its own name and directory", () => { + const guidance = buildSelfImprovementGuidance({ + kind: "job", + name: "Test Enforcer", + directory: "/Users/someone/dev/apps/dispatch", + }); + + expect(guidance).toContain( + 'use update_job with name "Test Enforcer", directory "/Users/someone/dev/apps/dispatch", and the complete revised prompt.' + ); + expect(guidance).not.toContain("update_template"); + }); + + it("opens on its own line so it cannot run into the prompt it is appended to", () => { + // renderTemplatePrompt concatenates this onto the end of the prompt with + // no separator of its own, so the leading break has to come from here. + const guidance = buildSelfImprovementGuidance({ + kind: "job", + name: "Test Enforcer", + directory: "/repo", + }); + + expect(guidance.startsWith("\nSelf-improvement:\n")).toBe(true); + }); +}); diff --git a/apps/web/src/hooks/use-pin-shortcuts.test.tsx b/apps/web/src/hooks/use-pin-shortcuts.test.tsx new file mode 100644 index 00000000..c89f75c6 --- /dev/null +++ b/apps/web/src/hooks/use-pin-shortcuts.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useRunPinShortcut } from "./use-pin-shortcuts"; + +vi.mock("@/lib/api", () => ({ api: vi.fn() })); +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const { api } = await import("@/lib/api"); +const { toast } = await import("sonner"); +const apiMock = vi.mocked(api); +const successMock = vi.mocked(toast.success); +const errorMock = vi.mocked(toast.error); + +let queryClient: QueryClient; + +function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); +} + +beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + apiMock.mockResolvedValue(null); +}); + +afterEach(() => { + cleanup(); + queryClient.clear(); + // restoreAllMocks only restores vi.spyOn spies, not module-factory vi.fn()s, + // so reset these explicitly — otherwise call history leaks between tests. + apiMock.mockReset(); + successMock.mockReset(); + errorMock.mockReset(); +}); + +/** + * Firing a shortcut is a write into a live agent session: the server resolves + * the prompt from the pin ID and types it into the terminal. The E2E suite + * proves the button reaches this endpoint once; what is only checkable here is + * the request the hook builds and which toast each outcome produces. + */ +describe("useRunPinShortcut", () => { + it("posts to the inject-pin endpoint for the given agent and pin", async () => { + const { result } = renderHook(() => useRunPinShortcut(), { wrapper }); + + act(() => { + result.current.mutate({ agentId: "agt_abc", pinId: "pin_xyz" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiMock).toHaveBeenCalledWith( + "/api/v1/agents/agt_abc/terminal/inject-pin/pin_xyz", + { method: "POST" } + ); + }); + + it("names the shortcut in the success toast so stacked toasts stay distinct", async () => { + const { result } = renderHook(() => useRunPinShortcut(), { wrapper }); + + act(() => { + result.current.mutate({ + agentId: "agt_abc", + pinId: "pin_xyz", + label: "Rebuild", + }); + }); + + await waitFor(() => expect(successMock).toHaveBeenCalledTimes(1)); + expect(successMock).toHaveBeenCalledWith('Sent "Rebuild" to agent'); + }); + + it("falls back to a generic success toast when the pin has no label", async () => { + const { result } = renderHook(() => useRunPinShortcut(), { wrapper }); + + act(() => { + result.current.mutate({ agentId: "agt_abc", pinId: "pin_xyz" }); + }); + + await waitFor(() => expect(successMock).toHaveBeenCalledTimes(1)); + expect(successMock).toHaveBeenCalledWith("Sent to agent"); + }); + + it("surfaces the server's message when the request fails", async () => { + apiMock.mockRejectedValue(new Error("Agent has no active session")); + const { result } = renderHook(() => useRunPinShortcut(), { wrapper }); + + act(() => { + result.current.mutate({ + agentId: "agt_abc", + pinId: "pin_xyz", + label: "Rebuild", + }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(errorMock).toHaveBeenCalledWith("Agent has no active session"); + }); + + it("falls back to generic copy when the rejection is not an Error", async () => { + // api() rejects with an Error on every HTTP path, but a network-layer + // throw or a future caller need not — and a bare string would render as + // "undefined" in the toast if the message were read off it blindly. + apiMock.mockRejectedValue("boom"); + const { result } = renderHook(() => useRunPinShortcut(), { wrapper }); + + act(() => { + result.current.mutate({ agentId: "agt_abc", pinId: "pin_xyz" }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(errorMock).toHaveBeenCalledWith("Failed to send shortcut"); + }); +}); diff --git a/apps/web/src/lib/pin-shortcut-icons.test.ts b/apps/web/src/lib/pin-shortcut-icons.test.ts new file mode 100644 index 00000000..25feeee6 --- /dev/null +++ b/apps/web/src/lib/pin-shortcut-icons.test.ts @@ -0,0 +1,44 @@ +import { GitPullRequest, Trash2, Zap } from "lucide-react"; +import { describe, expect, it } from "vitest"; + +import { resolvePinShortcutIcon } from "./pin-shortcut-icons"; + +/** + * The name allowlist itself is pinned by the server-side lockstep test + * (apps/server/test/pin-shortcut-icons-lockstep.test.ts), which compares this + * module's keys against the dispatch_pin schema. What is left uncovered — and + * what this file covers — is the resolution step: an agent supplies an + * arbitrary string, and every path out of it has to yield a real component, + * because the caller renders the result directly (``) with no guard. + */ +describe("resolvePinShortcutIcon", () => { + it("resolves a name from the allowlist to its component", () => { + expect(resolvePinShortcutIcon("trash")).toBe(Trash2); + }); + + it("resolves a hyphenated name", () => { + expect(resolvePinShortcutIcon("pull-request")).toBe(GitPullRequest); + }); + + it("falls back to the zap glyph when no icon was set", () => { + expect(resolvePinShortcutIcon(undefined)).toBe(Zap); + }); + + it("falls back for a name that is not on the allowlist", () => { + expect(resolvePinShortcutIcon("nonexistent")).toBe(Zap); + }); + + it("falls back for an empty name rather than treating it as a lookup", () => { + expect(resolvePinShortcutIcon("")).toBe(Zap); + }); + + // The lookup is an own-property check precisely so these fall through: `in` + // or a bare `PIN_SHORTCUT_ICONS[name]` would hand the caller Object.prototype + // members, which are not components and throw during render. + it.each(["constructor", "__proto__", "toString", "hasOwnProperty"])( + "falls back for the inherited key %j", + (name) => { + expect(resolvePinShortcutIcon(name)).toBe(Zap); + } + ); +});