feat: notifier-composio plugin + integration tests for all plugins (#7)
* feat: implement notifier and terminal plugins (desktop, slack, webhook, iterm2, web)
Implement all 5 notification and terminal UI plugins for the agent
orchestrator, replacing stub files with full implementations of the
Notifier and Terminal interfaces from @agent-orchestrator/core.
Notifier plugins:
- notifier-desktop: OS notifications via osascript (macOS) / notify-send
(Linux) with priority-based sound (urgent=sound, others=silent)
- notifier-slack: Slack Incoming Webhooks with Block Kit formatting,
action buttons, PR links, CI status context blocks
- notifier-webhook: Generic HTTP POST with JSON payloads, configurable
headers, and retry with backoff (default 2 retries)
Terminal plugins:
- terminal-iterm2: AppleScript-based iTerm2 tab management — detects
existing tabs by profile name, creates/reuses tabs (ported from
scripts/open-iterm-tab reference implementation)
- terminal-web: Web terminal session tracking for the dashboard's
xterm.js frontend, providing URL generation and open-state tracking
All plugins follow the PluginModule pattern (manifest + create export)
and pass typecheck cleanly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add comprehensive vitest suites for all notifier and terminal plugins
Add 96 unit tests across 5 plugin packages covering:
- notifier-desktop: priority-based sound mapping, osascript/notify-send
command generation, platform detection (macOS/Linux/unsupported),
title formatting, error propagation
- notifier-slack: Block Kit message structure (header/section/context/
divider blocks), priority emoji mapping, PR link and CI status
rendering, action button generation (URL and callback variants),
channel routing, post method
- notifier-webhook: JSON payload serialization, custom headers, retry
logic (success after retry, exhausted retries, zero retries, network
errors), timestamp ISO serialization
- terminal-iterm2: AppleScript command generation, tab reuse via profile
name detection, new tab creation with tmux attach, runtimeHandle
preference over session ID, batch openAll with delays, error fallback
- terminal-web: session open tracking, dashboard URL configuration,
openAll batch registration, independent state per instance
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address PR review feedback — security, retry logic, side effects
- Add AppleScript injection escaping in terminal-iterm2 and notifier-desktop
- Webhook: only retry on 429/5xx (not 4xx), add exponential backoff
- terminal-iterm2: fix isSessionOpen selecting tab (side effect), remove dead openNewWindow
- notifier-slack: type-guard event.data access, add URL validation
- notifier-webhook: add URL validation, remove unused config interfaces
- Update all tests to cover new behaviors (108 tests passing)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: codex review — shell injection, iTerm2 name lookup, notify-send args, retry validation
- terminal-iterm2: shell-escape session names in tmux command (single-quote wrapping)
- terminal-iterm2: use `name of aSession` instead of `profile name` for tab lookup
- notifier-desktop: fix notify-send arg order (options before title/body)
- notifier-webhook: clamp retries/retryDelayMs to safe values (non-negative, finite)
- notifier-slack: sanitize action_id to [a-z0-9_] characters only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: lint/format compliance after rebase on main
- Fix ESLint errors: remove unused WebTerminalConfig, ignore next-env.d.ts
- Run prettier on all files for consistent formatting
- Update pnpm-lock.yaml with new lint dependencies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: codex review round 2 — printf injection, platform guard, config validation
- terminal-iterm2: use shell-escaped name in printf title (not just tmux target)
- terminal-iterm2: add platform guard — no-op with warning on non-macOS
- notifier-webhook: validate customHeaders are string:string before spreading
- notifier-desktop: validate sound config as boolean (reject string "false")
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: update lockfile after rebase on main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add notifier-composio plugin and integration tests for all plugins
Add a new notifier-composio plugin as the recommended notification
transport using Composio's unified API for Slack, Discord, and Gmail.
Create comprehensive integration tests (79 tests across 6 files) for
all notifier and terminal plugins, mocking only I/O boundaries.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address PR review feedback — security, retry logic, side effects
- Shell injection fix: double-escape (shell + AppleScript) in iTerm2 printf/tmux
- iTerm2 no-window crash: create window if none exists
- Composio graceful degradation: warn instead of throw when SDK missing
- Composio emailTo validation: require emailTo when defaultApp is gmail
- AbortSignal.timeout() replaces manual AbortController + {once: true} listener
- Discord channelName fallback for channel_id
- Slack empty action_id fallback
- Dashboard port: terminal-web default changed from 9847 to 3000
- escapeAppleScript deduplicated into core/utils.ts
- Consistent vitest version (^3.0.0) for composio plugin
- Remove duplicate eslint ignore entry
- Integration tests updated for shared event-factory helper
- Unit tests updated for new validation and escaping behavior
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: update lockfile after rebase on main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: prevent double ao_ prefix in Slack action_id fallback
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: decouple Linux urgency from sound config, deduplicate validateUrl
- Linux --urgency=critical now driven by event priority, not sound config
- Moved validateUrl to core/utils.ts, imported by slack and webhook plugins
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: handle ESM ERR_MODULE_NOT_FOUND for composio-core detection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: codex review round 2 — printf injection, platform guard, config validation
- Slack action_id: append index for uniqueness (two "Retry" buttons no
longer collide)
- Composio Gmail subject: extract GMAIL_SUBJECT constant so notify() and
post() use the same value
- Agent integration tests: require API key env vars before running to
prevent CI timeout when secrets are not configured
- Update lockfile after rebase on main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: parallelize integration tests and increase CI timeout
- Remove singleFork: true from vitest config — all integration test
files use unique session prefixes so they're safe to run concurrently
- Increase CI timeout from 15 to 20 minutes as safety margin for agent
tests that make real API calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: prevent unhandled promise rejection after timeout in composio notifier
Attach a no-op .catch() to the executeAction promise so that if the
timeout fires first and the action later rejects, it doesn't trigger
an unhandledRejection event.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
06b5ec3267
commit
90f14a6ca5
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
integration:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ const DefaultPluginsSchema = z.object({
|
|||
runtime: z.string().default("tmux"),
|
||||
agent: z.string().default("claude-code"),
|
||||
workspace: z.string().default("worktree"),
|
||||
notifiers: z.array(z.string()).default(["desktop"]),
|
||||
notifiers: z.array(z.string()).default(["composio", "desktop"]),
|
||||
});
|
||||
|
||||
const OrchestratorConfigSchema = z.object({
|
||||
|
|
@ -89,10 +89,10 @@ const OrchestratorConfigSchema = z.object({
|
|||
projects: z.record(ProjectConfigSchema),
|
||||
notifiers: z.record(NotifierConfigSchema).default({}),
|
||||
notificationRouting: z.record(z.array(z.string())).default({
|
||||
urgent: ["desktop", "slack"],
|
||||
action: ["desktop", "slack"],
|
||||
warning: ["slack"],
|
||||
info: ["slack"],
|
||||
urgent: ["desktop", "composio"],
|
||||
action: ["desktop", "composio"],
|
||||
warning: ["composio"],
|
||||
info: ["composio"],
|
||||
}),
|
||||
reactions: z.record(ReactionConfigSchema).default({}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,4 +45,4 @@ export { createLifecycleManager } from "./lifecycle-manager.js";
|
|||
export type { LifecycleManagerDeps } from "./lifecycle-manager.js";
|
||||
|
||||
// Shared utilities
|
||||
export { shellEscape } from "./utils.js";
|
||||
export { shellEscape, escapeAppleScript, validateUrl } from "./utils.js";
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> =
|
|||
// SCM
|
||||
{ slot: "scm", name: "github", pkg: "@agent-orchestrator/plugin-scm-github" },
|
||||
// Notifiers
|
||||
{ slot: "notifier", name: "composio", pkg: "@agent-orchestrator/plugin-notifier-composio" },
|
||||
{ slot: "notifier", name: "desktop", pkg: "@agent-orchestrator/plugin-notifier-desktop" },
|
||||
{ slot: "notifier", name: "slack", pkg: "@agent-orchestrator/plugin-notifier-slack" },
|
||||
{ slot: "notifier", name: "webhook", pkg: "@agent-orchestrator/plugin-notifier-webhook" },
|
||||
|
|
|
|||
|
|
@ -11,3 +11,21 @@
|
|||
export function shellEscape(arg: string): string {
|
||||
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe interpolation inside AppleScript double-quoted strings.
|
||||
* Handles backslashes and double quotes which would otherwise break or inject.
|
||||
*/
|
||||
export function escapeAppleScript(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a URL starts with http:// or https://.
|
||||
* Throws with a descriptive error including the plugin label if invalid.
|
||||
*/
|
||||
export function validateUrl(url: string, label: string): void {
|
||||
if (!url.startsWith("https://") && !url.startsWith("http://")) {
|
||||
throw new Error(`[${label}] Invalid url: must be http(s), got "${url}"`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@
|
|||
"@agent-orchestrator/plugin-tracker-linear": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agent-orchestrator/plugin-notifier-desktop": "workspace:*",
|
||||
"@agent-orchestrator/plugin-notifier-slack": "workspace:*",
|
||||
"@agent-orchestrator/plugin-notifier-webhook": "workspace:*",
|
||||
"@agent-orchestrator/plugin-notifier-composio": "workspace:*",
|
||||
"@agent-orchestrator/plugin-terminal-iterm2": "workspace:*",
|
||||
"@agent-orchestrator/plugin-terminal-web": "workspace:*",
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ async function canAiderConnect(bin: string): Promise<boolean> {
|
|||
|
||||
const tmuxOk = await isTmuxAvailable();
|
||||
const aiderBin = await findAiderBinary();
|
||||
const aiderReady = aiderBin !== null && (await canAiderConnect(aiderBin));
|
||||
const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY);
|
||||
// Skip the expensive canAiderConnect probe if no API key is available
|
||||
const aiderReady = hasApiKey && aiderBin !== null && (await canAiderConnect(aiderBin));
|
||||
const canRun = tmuxOk && aiderReady;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ async function findClaudeBinary(): Promise<string | null> {
|
|||
|
||||
const tmuxOk = await isTmuxAvailable();
|
||||
const claudeBin = await findClaudeBinary();
|
||||
const canRun = tmuxOk && claudeBin !== null;
|
||||
const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY);
|
||||
const canRun = tmuxOk && claudeBin !== null && hasApiKey;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ async function findCodexBinary(): Promise<string | null> {
|
|||
|
||||
const tmuxOk = await isTmuxAvailable();
|
||||
const codexBin = await findCodexBinary();
|
||||
const canRun = tmuxOk && codexBin !== null;
|
||||
const hasApiKey = Boolean(process.env.OPENAI_API_KEY);
|
||||
const canRun = tmuxOk && codexBin !== null && hasApiKey;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ async function canOpencodeRun(bin: string): Promise<boolean> {
|
|||
|
||||
const tmuxOk = await isTmuxAvailable();
|
||||
const opencodeBin = await findOpencodeBinary();
|
||||
const opencodeReady = opencodeBin !== null && (await canOpencodeRun(opencodeBin));
|
||||
const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY);
|
||||
// Skip the expensive canOpencodeRun probe if no API key is available
|
||||
const opencodeReady = hasApiKey && opencodeBin !== null && (await canOpencodeRun(opencodeBin));
|
||||
const canRun = tmuxOk && opencodeReady;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import type {
|
||||
OrchestratorEvent,
|
||||
Session,
|
||||
NotifyAction,
|
||||
EventPriority,
|
||||
EventType,
|
||||
SessionStatus,
|
||||
ActivityState,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
/**
|
||||
* Create a test OrchestratorEvent with sensible defaults.
|
||||
* Override any field as needed.
|
||||
*/
|
||||
export function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
||||
return {
|
||||
id: "evt-test-1",
|
||||
type: "session.spawned" as EventType,
|
||||
priority: "info" as EventPriority,
|
||||
sessionId: "app-1",
|
||||
projectId: "my-project",
|
||||
timestamp: new Date("2025-06-15T12:00:00Z"),
|
||||
message: "Session app-1 spawned successfully",
|
||||
data: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test Session with sensible defaults.
|
||||
* Override any field as needed.
|
||||
*/
|
||||
export function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "my-project",
|
||||
status: "working" as SessionStatus,
|
||||
activity: "active" as ActivityState,
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/workspace",
|
||||
runtimeHandle: null,
|
||||
agentInfo: null,
|
||||
createdAt: new Date("2025-06-15T12:00:00Z"),
|
||||
lastActivityAt: new Date("2025-06-15T12:00:00Z"),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a set of test NotifyActions.
|
||||
*/
|
||||
export function makeActions(overrides?: Partial<NotifyAction>[]): NotifyAction[] {
|
||||
const defaults: NotifyAction[] = [
|
||||
{ label: "View PR", url: "https://github.com/org/repo/pull/42" },
|
||||
{ label: "Kill Session", callbackEndpoint: "/api/sessions/app-1/kill" },
|
||||
];
|
||||
|
||||
if (!overrides) return defaults;
|
||||
|
||||
return overrides.map((o, i) => ({
|
||||
...defaults[i % defaults.length],
|
||||
...o,
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* Integration tests for notifier-composio.
|
||||
*
|
||||
* Uses _clientOverride to inject a mock Composio client at the I/O boundary.
|
||||
* Everything else runs for real: config parsing, tool slug routing, message formatting.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { NotifyAction } from "@agent-orchestrator/core";
|
||||
import composioPlugin from "@agent-orchestrator/plugin-notifier-composio";
|
||||
import { makeEvent } from "./helpers/event-factory.js";
|
||||
|
||||
const mockExecuteAction = vi.fn().mockResolvedValue({ successful: true });
|
||||
const mockClient = { executeAction: mockExecuteAction };
|
||||
|
||||
describe("notifier-composio integration", () => {
|
||||
const originalEnv = process.env.COMPOSIO_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.COMPOSIO_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.COMPOSIO_API_KEY = originalEnv;
|
||||
} else {
|
||||
delete process.env.COMPOSIO_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
describe("config -> tool slug routing", () => {
|
||||
it("slack app routes to SLACK_SEND_MESSAGE with channel", async () => {
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
defaultApp: "slack",
|
||||
channelName: "#deploys",
|
||||
_clientOverride: mockClient,
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "SLACK_SEND_MESSAGE",
|
||||
params: expect.objectContaining({
|
||||
channel: "#deploys",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("discord app routes to DISCORD_SEND_MESSAGE with channel_id", async () => {
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
defaultApp: "discord",
|
||||
channelId: "1234567890",
|
||||
_clientOverride: mockClient,
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "DISCORD_SEND_MESSAGE",
|
||||
params: expect.objectContaining({
|
||||
channel_id: "1234567890",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("gmail app routes to GMAIL_SEND_EMAIL with to/subject/body", async () => {
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
defaultApp: "gmail",
|
||||
emailTo: "admin@example.com",
|
||||
_clientOverride: mockClient,
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "GMAIL_SEND_EMAIL",
|
||||
params: expect.objectContaining({
|
||||
to: "admin@example.com",
|
||||
subject: "Agent Orchestrator Notification",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("message formatting pipeline", () => {
|
||||
it("includes priority emoji, event type, session ID, and message", async () => {
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
_clientOverride: mockClient,
|
||||
});
|
||||
await notifier.notify(
|
||||
makeEvent({ priority: "urgent", type: "ci.failing", sessionId: "app-5" }),
|
||||
);
|
||||
|
||||
const text = mockExecuteAction.mock.calls[0][0].params.text as string;
|
||||
expect(text).toContain("\u{1F6A8}"); // urgent emoji
|
||||
expect(text).toContain("ci.failing");
|
||||
expect(text).toContain("app-5");
|
||||
});
|
||||
|
||||
it("includes PR URL when present in event data", async () => {
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
_clientOverride: mockClient,
|
||||
});
|
||||
await notifier.notify(
|
||||
makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/99" } }),
|
||||
);
|
||||
|
||||
const text = mockExecuteAction.mock.calls[0][0].params.text as string;
|
||||
expect(text).toContain("https://github.com/org/repo/pull/99");
|
||||
});
|
||||
|
||||
it("omits PR URL when not a string", async () => {
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
_clientOverride: mockClient,
|
||||
});
|
||||
await notifier.notify(makeEvent({ data: { prUrl: 123 } }));
|
||||
|
||||
const text = mockExecuteAction.mock.calls[0][0].params.text as string;
|
||||
expect(text).not.toContain("PR:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyWithActions pipeline", () => {
|
||||
it("includes action labels and URLs in message text", async () => {
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
_clientOverride: mockClient,
|
||||
});
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Merge PR", url: "https://github.com/merge" },
|
||||
{ label: "Kill Session", callbackEndpoint: "/api/kill" },
|
||||
];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const text = mockExecuteAction.mock.calls[0][0].params.text as string;
|
||||
expect(text).toContain("Merge PR: https://github.com/merge");
|
||||
expect(text).toContain("- Kill Session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("post pipeline", () => {
|
||||
it("sends plain text with channel override", async () => {
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
channelName: "#default",
|
||||
_clientOverride: mockClient,
|
||||
});
|
||||
await notifier.post!("All sessions complete", { channel: "#override" });
|
||||
|
||||
const args = mockExecuteAction.mock.calls[0][0].params;
|
||||
expect(args.text).toBe("All sessions complete");
|
||||
expect(args.channel).toBe("#override");
|
||||
});
|
||||
|
||||
it("returns null", async () => {
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
_clientOverride: mockClient,
|
||||
});
|
||||
const result = await notifier.post!("test");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("unsuccessful result throws descriptive error", async () => {
|
||||
const failClient = {
|
||||
executeAction: vi.fn().mockResolvedValue({
|
||||
successful: false,
|
||||
error: "Channel not found",
|
||||
}),
|
||||
};
|
||||
|
||||
const notifier = composioPlugin.create({
|
||||
composioApiKey: "key",
|
||||
_clientOverride: failClient,
|
||||
});
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("Channel not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Integration tests for notifier-desktop.
|
||||
*
|
||||
* Mocks ONLY the I/O boundary: node:child_process and node:os.
|
||||
* Everything else runs for real: config parsing, escaping chains, formatting.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||
import type { NotifyAction } from "@agent-orchestrator/core";
|
||||
import { makeEvent } from "./helpers/event-factory.js";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
platform: vi.fn(() => "darwin"),
|
||||
}));
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
|
||||
const mockExecFile = execFile as unknown as Mock;
|
||||
const mockPlatform = platform as unknown as Mock;
|
||||
|
||||
// Import the full plugin module — config parsing, escaping, formatting all run for real
|
||||
import desktopPlugin from "@agent-orchestrator/plugin-notifier-desktop";
|
||||
|
||||
describe("notifier-desktop integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPlatform.mockReturnValue("darwin");
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null) => void) => {
|
||||
cb(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("config -> behavior flow", () => {
|
||||
it("sound=false config + urgent event -> no sound clause in osascript", async () => {
|
||||
const notifier = desktopPlugin.create({ sound: false });
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).not.toContain("sound name");
|
||||
expect(script).toContain("URGENT");
|
||||
});
|
||||
|
||||
it("default config + urgent event -> sound clause present", async () => {
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain('sound name "default"');
|
||||
});
|
||||
|
||||
it("default config + info event -> no sound clause", async () => {
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent({ priority: "info" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).not.toContain("sound name");
|
||||
});
|
||||
|
||||
it("sound=true config behaves same as default", async () => {
|
||||
const notifier = desktopPlugin.create({ sound: true });
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain('sound name "default"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("escaping chain integrity", () => {
|
||||
it("handles double quotes, backslashes, and single quotes simultaneously", async () => {
|
||||
const notifier = desktopPlugin.create();
|
||||
const nastySession = `test"with\\back'slash`;
|
||||
const nastyMessage = `msg "has" all\\the'chars`;
|
||||
|
||||
await notifier.notify(
|
||||
makeEvent({ sessionId: nastySession, message: nastyMessage }),
|
||||
);
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
|
||||
// AppleScript escaping: " -> \" and \ -> \\
|
||||
expect(script).toContain('test\\"with\\\\back');
|
||||
expect(script).toContain('msg \\"has\\" all\\\\the');
|
||||
|
||||
// The script should be valid AppleScript structure
|
||||
expect(script).toContain("display notification");
|
||||
expect(script).toContain("with title");
|
||||
});
|
||||
|
||||
it("escapes newlines in message text (renders literally in notification)", async () => {
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent({ message: "line1\nline2" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
// newlines in AppleScript strings are ok, they render as literal newlines
|
||||
expect(script).toContain("line1\nline2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform routing", () => {
|
||||
it("darwin -> osascript with -e flag", async () => {
|
||||
mockPlatform.mockReturnValue("darwin");
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecFile.mock.calls[0][0]).toBe("osascript");
|
||||
expect(mockExecFile.mock.calls[0][1][0]).toBe("-e");
|
||||
});
|
||||
|
||||
it("linux -> notify-send with title and message as separate args", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent({ sessionId: "backend-1", message: "Test msg" }));
|
||||
|
||||
expect(mockExecFile.mock.calls[0][0]).toBe("notify-send");
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain("Agent Orchestrator [backend-1]");
|
||||
expect(args).toContain("Test msg");
|
||||
});
|
||||
|
||||
it("linux + urgent -> --urgency=critical before title", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args[0]).toBe("--urgency=critical");
|
||||
});
|
||||
|
||||
it("linux + info -> no --urgency flag", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent({ priority: "info" }));
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).not.toContain("--urgency=critical");
|
||||
});
|
||||
|
||||
it("win32 -> no execFile call, warns", async () => {
|
||||
mockPlatform.mockReturnValue("win32");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const notifier = desktopPlugin.create();
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("win32"));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyWithActions full pipeline", () => {
|
||||
it("formats action labels into notification body text", async () => {
|
||||
const notifier = desktopPlugin.create();
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Merge PR", url: "https://github.com/pr/1" },
|
||||
{ label: "Kill", callbackEndpoint: "/api/kill" },
|
||||
];
|
||||
|
||||
await notifier.notifyWithActions!(makeEvent({ priority: "urgent" }), actions);
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain("Merge PR");
|
||||
expect(script).toContain("Kill");
|
||||
expect(script).toContain('sound name "default"');
|
||||
});
|
||||
|
||||
it("action labels with special chars are escaped in AppleScript", async () => {
|
||||
const notifier = desktopPlugin.create();
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: 'Fix "bug"', url: "https://example.com" },
|
||||
];
|
||||
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain('Fix \\"bug\\"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("error propagation", () => {
|
||||
it("rejects when execFile fails on darwin", async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null) => void) => {
|
||||
cb(new Error("osascript: command not found"));
|
||||
},
|
||||
);
|
||||
|
||||
const notifier = desktopPlugin.create();
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("osascript: command not found");
|
||||
});
|
||||
|
||||
it("rejects when execFile fails on linux", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null) => void) => {
|
||||
cb(new Error("notify-send: not found"));
|
||||
},
|
||||
);
|
||||
|
||||
const notifier = desktopPlugin.create();
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("notify-send: not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* Integration tests for notifier-slack.
|
||||
*
|
||||
* Mocks ONLY the I/O boundary: global fetch.
|
||||
* Everything else runs for real: config parsing, Block Kit construction, channel routing.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { NotifyAction, EventPriority } from "@agent-orchestrator/core";
|
||||
import slackPlugin from "@agent-orchestrator/plugin-notifier-slack";
|
||||
import { makeEvent } from "./helpers/event-factory.js";
|
||||
|
||||
function mockFetchOk() {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve("ok"),
|
||||
});
|
||||
}
|
||||
|
||||
describe("notifier-slack integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("config -> Block Kit structure", () => {
|
||||
it("full event with prUrl + ciStatus produces complete blocks array", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({
|
||||
webhookUrl: "https://hooks.slack.com/test",
|
||||
channel: "#deploys",
|
||||
username: "TestBot",
|
||||
});
|
||||
|
||||
await notifier.notify(
|
||||
makeEvent({
|
||||
priority: "urgent",
|
||||
sessionId: "backend-3",
|
||||
projectId: "integrator",
|
||||
message: "CI is failing on backend-3",
|
||||
data: {
|
||||
prUrl: "https://github.com/org/repo/pull/42",
|
||||
ciStatus: "failing",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
|
||||
// Verify full structure
|
||||
expect(body.username).toBe("TestBot");
|
||||
expect(body.channel).toBe("#deploys");
|
||||
|
||||
// Block 0: header
|
||||
expect(body.blocks[0].type).toBe("header");
|
||||
expect(body.blocks[0].text.type).toBe("plain_text");
|
||||
expect(body.blocks[0].text.text).toContain(":rotating_light:");
|
||||
expect(body.blocks[0].text.text).toContain("session.spawned");
|
||||
expect(body.blocks[0].text.text).toContain("backend-3");
|
||||
expect(body.blocks[0].text.emoji).toBe(true);
|
||||
|
||||
// Block 1: message section
|
||||
expect(body.blocks[1].type).toBe("section");
|
||||
expect(body.blocks[1].text.type).toBe("mrkdwn");
|
||||
expect(body.blocks[1].text.text).toBe("CI is failing on backend-3");
|
||||
|
||||
// Block 2: context with project/priority/time
|
||||
expect(body.blocks[2].type).toBe("context");
|
||||
expect(body.blocks[2].elements[0].text).toContain("*Project:* integrator");
|
||||
expect(body.blocks[2].elements[0].text).toContain("*Priority:* urgent");
|
||||
|
||||
// Block 3: PR link
|
||||
const prBlock = body.blocks.find(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.type === "section" &&
|
||||
typeof (b as { text?: { text?: string } }).text?.text === "string" &&
|
||||
(b as { text: { text: string } }).text.text.includes("View Pull Request"),
|
||||
);
|
||||
expect(prBlock).toBeDefined();
|
||||
expect(prBlock.text.text).toContain("https://github.com/org/repo/pull/42");
|
||||
|
||||
// CI status block
|
||||
const ciBlock = body.blocks.find(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.type === "context" &&
|
||||
Array.isArray((b as { elements?: unknown[] }).elements) &&
|
||||
typeof (b as { elements: Array<{ text?: string }> }).elements[0]?.text === "string" &&
|
||||
(b as { elements: Array<{ text: string }> }).elements[0].text.includes("CI:"),
|
||||
);
|
||||
expect(ciBlock).toBeDefined();
|
||||
expect(ciBlock.elements[0].text).toContain(":x:");
|
||||
expect(ciBlock.elements[0].text).toContain("failing");
|
||||
|
||||
// Last block: divider
|
||||
expect(body.blocks[body.blocks.length - 1].type).toBe("divider");
|
||||
});
|
||||
|
||||
it("passing CI uses check mark emoji", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ data: { ciStatus: "passing" } }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const ciBlock = body.blocks.find(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.type === "context" &&
|
||||
Array.isArray((b as { elements?: unknown[] }).elements) &&
|
||||
typeof (b as { elements: Array<{ text?: string }> }).elements[0]?.text === "string" &&
|
||||
(b as { elements: Array<{ text: string }> }).elements[0].text.includes("CI:"),
|
||||
);
|
||||
expect(ciBlock.elements[0].text).toContain(":white_check_mark:");
|
||||
});
|
||||
|
||||
it("event without prUrl or ciStatus omits those blocks", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ data: {} }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
// Should have: header, section, context, divider = 4 blocks
|
||||
expect(body.blocks).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority emoji routing", () => {
|
||||
it.each([
|
||||
["urgent", ":rotating_light:"],
|
||||
["action", ":point_right:"],
|
||||
["warning", ":warning:"],
|
||||
["info", ":information_source:"],
|
||||
] as Array<[EventPriority, string]>)(
|
||||
"priority %s -> emoji %s",
|
||||
async (priority, expectedEmoji) => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ priority }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.blocks[0].text.text).toContain(expectedEmoji);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("notifyWithActions full pipeline", () => {
|
||||
it("renders URL actions as buttons and callback actions with action_id", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Merge PR", url: "https://github.com/merge" },
|
||||
{ label: "Kill Session", callbackEndpoint: "/api/kill/app-1" },
|
||||
];
|
||||
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const actionsBlock = body.blocks.find((b: Record<string, unknown>) => b.type === "actions");
|
||||
expect(actionsBlock).toBeDefined();
|
||||
expect(actionsBlock.elements).toHaveLength(2);
|
||||
|
||||
// URL button
|
||||
expect(actionsBlock.elements[0].type).toBe("button");
|
||||
expect(actionsBlock.elements[0].text.text).toBe("Merge PR");
|
||||
expect(actionsBlock.elements[0].url).toBe("https://github.com/merge");
|
||||
|
||||
// Callback button
|
||||
expect(actionsBlock.elements[1].type).toBe("button");
|
||||
expect(actionsBlock.elements[1].action_id).toBe("ao_kill_session_1");
|
||||
expect(actionsBlock.elements[1].value).toBe("/api/kill/app-1");
|
||||
});
|
||||
|
||||
it("actions without url or callback are filtered out", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "No Link" },
|
||||
{ label: "Has Link", url: "https://example.com" },
|
||||
];
|
||||
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const actionsBlock = body.blocks.find((b: Record<string, unknown>) => b.type === "actions");
|
||||
expect(actionsBlock.elements).toHaveLength(1);
|
||||
expect(actionsBlock.elements[0].text.text).toBe("Has Link");
|
||||
});
|
||||
|
||||
it("empty valid actions list produces no actions block", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notifyWithActions!(makeEvent(), [{ label: "No Link" }]);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const actionsBlock = body.blocks.find((b: Record<string, unknown>) => b.type === "actions");
|
||||
expect(actionsBlock).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("post full pipeline", () => {
|
||||
it("sends text with username and channel", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({
|
||||
webhookUrl: "https://hooks.slack.com/test",
|
||||
channel: "#default-channel",
|
||||
username: "CustomBot",
|
||||
});
|
||||
await notifier.post!("Summary message");
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.text).toBe("Summary message");
|
||||
expect(body.username).toBe("CustomBot");
|
||||
expect(body.channel).toBe("#default-channel");
|
||||
});
|
||||
|
||||
it("context channel overrides default channel", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({
|
||||
webhookUrl: "https://hooks.slack.com/test",
|
||||
channel: "#default",
|
||||
});
|
||||
await notifier.post!("test", { channel: "#specific" });
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.channel).toBe("#specific");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-op behavior", () => {
|
||||
it("all methods are no-ops when no webhookUrl", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const notifier = slackPlugin.create();
|
||||
await notifier.notify(makeEvent());
|
||||
await notifier.notifyWithActions!(makeEvent(), [{ label: "Test", url: "https://x.com" }]);
|
||||
const result = await notifier.post!("test");
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error propagation", () => {
|
||||
it("non-ok response throws with status and body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal Server Error"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow(
|
||||
"Slack webhook failed (500): Internal Server Error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timestamp formatting", () => {
|
||||
it("includes Unix timestamp in Slack date format", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const ts = new Date("2025-06-15T12:00:00Z");
|
||||
const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ timestamp: ts }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const contextText = body.blocks[2].elements[0].text;
|
||||
const unixTs = Math.floor(ts.getTime() / 1000);
|
||||
expect(contextText).toContain(`<!date^${unixTs}^`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
/**
|
||||
* Integration tests for notifier-webhook.
|
||||
*
|
||||
* Mocks ONLY the I/O boundary: global fetch + vi.useFakeTimers().
|
||||
* Everything else runs for real: config parsing, retry logic, payload serialization.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { NotifyAction } from "@agent-orchestrator/core";
|
||||
import webhookPlugin from "@agent-orchestrator/plugin-notifier-webhook";
|
||||
import { makeEvent } from "./helpers/event-factory.js";
|
||||
|
||||
describe("notifier-webhook integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("payload serialization", () => {
|
||||
it("notify serializes event with ISO timestamp and preserves data", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({ url: "https://example.com/hook" });
|
||||
const event = makeEvent({
|
||||
type: "ci.failing",
|
||||
priority: "action",
|
||||
message: "CI check failed",
|
||||
data: { checkName: "lint" },
|
||||
});
|
||||
await notifier.notify(event);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.type).toBe("notification");
|
||||
expect(body.event.id).toBe("evt-test-1");
|
||||
expect(body.event.type).toBe("ci.failing");
|
||||
expect(body.event.priority).toBe("action");
|
||||
expect(body.event.sessionId).toBe("app-1");
|
||||
expect(body.event.projectId).toBe("my-project");
|
||||
expect(body.event.timestamp).toBe("2025-06-15T12:00:00.000Z");
|
||||
expect(body.event.message).toBe("CI check failed");
|
||||
expect(body.event.data).toEqual({ checkName: "lint" });
|
||||
});
|
||||
|
||||
it("notifyWithActions includes actions array in payload", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Merge", url: "https://github.com/merge" },
|
||||
{ label: "Kill", callbackEndpoint: "/api/kill" },
|
||||
];
|
||||
|
||||
const notifier = webhookPlugin.create({ url: "https://example.com/hook" });
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.type).toBe("notification_with_actions");
|
||||
expect(body.actions).toEqual([
|
||||
{ label: "Merge", url: "https://github.com/merge", callbackEndpoint: undefined },
|
||||
{ label: "Kill", url: undefined, callbackEndpoint: "/api/kill" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("post includes message and context in payload", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({ url: "https://example.com/hook" });
|
||||
await notifier.post!("All sessions done", { projectId: "my-project", channel: "#ops" });
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.type).toBe("message");
|
||||
expect(body.message).toBe("All sessions done");
|
||||
expect(body.context).toEqual({ projectId: "my-project", channel: "#ops" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom headers", () => {
|
||||
it("merges custom headers with Content-Type", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({
|
||||
url: "https://example.com/hook",
|
||||
headers: { Authorization: "Bearer tok", "X-Custom": "val" },
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1].headers;
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
expect(headers["Authorization"]).toBe("Bearer tok");
|
||||
expect(headers["X-Custom"]).toBe("val");
|
||||
});
|
||||
|
||||
it("ignores non-string header values", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({
|
||||
url: "https://example.com/hook",
|
||||
headers: { valid: "yes", invalid: 42 },
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1].headers;
|
||||
expect(headers["valid"]).toBe("yes");
|
||||
expect(headers["invalid"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("retry timing with fake timers", () => {
|
||||
it("retries=2, retryDelayMs=100: 503, 503, 200 -> 3 fetch calls with exponential backoff", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("unavailable") })
|
||||
.mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("unavailable") })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 100,
|
||||
});
|
||||
|
||||
const promise = notifier.notify(makeEvent());
|
||||
|
||||
// First attempt happens immediately
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// First retry after 100ms (100 * 2^0)
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second retry after 200ms (100 * 2^1)
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("retries=1, retryDelayMs=500: delay * 2^0 = 500ms for first retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("error") })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 1,
|
||||
retryDelayMs: 500,
|
||||
});
|
||||
|
||||
const promise = notifier.notify(makeEvent());
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// At 499ms: not yet retried
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// At 500ms: retry fires
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-retryable errors", () => {
|
||||
it.each([400, 401, 403, 404])(
|
||||
"HTTP %d -> single fetch call, immediate rejection",
|
||||
async (status) => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status,
|
||||
text: () => Promise.resolve("client error"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 3,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow(
|
||||
`Webhook POST failed (${status})`,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("retryable errors", () => {
|
||||
it("429 is retried", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 429, text: () => Promise.resolve("rate limited") })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 1,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("network errors (ECONNREFUSED) are retried", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 1,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws after all retries exhausted", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("server error"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (500)");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
||||
});
|
||||
});
|
||||
|
||||
describe("config validation", () => {
|
||||
it("throws on non-http(s) URL", () => {
|
||||
expect(() => webhookPlugin.create({ url: "ftp://example.com" })).toThrow("must be http(s)");
|
||||
});
|
||||
|
||||
it("accepts http URL", () => {
|
||||
expect(() => webhookPlugin.create({ url: "http://localhost:3000/hook" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("retries defaults to 2 when not specified", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("error"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({ url: "https://example.com/hook", retryDelayMs: 1 });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 + 2 default retries
|
||||
});
|
||||
|
||||
it("retries=0 means no retries", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("error"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = webhookPlugin.create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 0,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-op behavior", () => {
|
||||
it("all methods no-op when no URL configured", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const notifier = webhookPlugin.create();
|
||||
await notifier.notify(makeEvent());
|
||||
await notifier.notifyWithActions!(makeEvent(), [{ label: "Test" }]);
|
||||
const result = await notifier.post!("msg");
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* Integration tests for terminal-iterm2.
|
||||
*
|
||||
* Mocks ONLY the I/O boundary: node:child_process and node:os.
|
||||
* Everything else runs for real: AppleScript generation, escaping chains, tab reuse logic.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
platform: vi.fn(() => "darwin"),
|
||||
}));
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
|
||||
const mockExecFile = execFile as unknown as Mock;
|
||||
const mockPlatform = platform as unknown as Mock;
|
||||
|
||||
import iterm2Plugin from "@agent-orchestrator/plugin-terminal-iterm2";
|
||||
import { makeSession } from "./helpers/event-factory.js";
|
||||
|
||||
function simulateOsascript(stdout: string) {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
|
||||
cb(null, stdout);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function simulateOsascriptSequence(results: string[]) {
|
||||
let callIndex = 0;
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
|
||||
const result = results[callIndex] ?? "NOT_FOUND";
|
||||
callIndex++;
|
||||
cb(null, result);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("terminal-iterm2 integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockPlatform.mockReturnValue("darwin");
|
||||
});
|
||||
|
||||
describe("escaping chain integrity", () => {
|
||||
it("handles double quotes, backslashes, and single quotes simultaneously in session name", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = iterm2Plugin.create();
|
||||
|
||||
const nastyName = `it's-a-"test"\\session`;
|
||||
await terminal.openSession(makeSession({ id: nastyName }));
|
||||
|
||||
// Call 0: findAndSelectExistingTab
|
||||
const findScript = mockExecFile.mock.calls[0][1][1] as string;
|
||||
// AppleScript escaping: " -> \" and \ -> \\
|
||||
expect(findScript).toContain('it\'s-a-\\"test\\"\\\\session');
|
||||
|
||||
// Call 1: openNewTab
|
||||
const openScript = mockExecFile.mock.calls[1][1][1] as string;
|
||||
// AppleScript escaping in set name
|
||||
expect(openScript).toContain('set name to "it\'s-a-\\"test\\"\\\\session"');
|
||||
// Shell escaping then AppleScript escaping in tmux command:
|
||||
// Shell: 'it'\''s-a-"test"\session' -> AppleScript doubles backslashes
|
||||
expect(openScript).toContain("tmux attach -t 'it'\\\\''s-a-\\\"test\\\"\\\\session'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tab reuse logic", () => {
|
||||
it("FOUND response -> no openNewTab call (only 1 execFile call)", async () => {
|
||||
simulateOsascript("FOUND\n");
|
||||
const terminal = iterm2Plugin.create();
|
||||
await terminal.openSession(makeSession());
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(1);
|
||||
// Should be the findAndSelectExistingTab call only
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain("select aWindow");
|
||||
expect(script).toContain("select aTab");
|
||||
});
|
||||
|
||||
it("NOT_FOUND response -> openNewTab call (2 execFile calls)", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = iterm2Plugin.create();
|
||||
await terminal.openSession(makeSession());
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
||||
const openScript = mockExecFile.mock.calls[1][1][1] as string;
|
||||
expect(openScript).toContain("create tab with default profile");
|
||||
});
|
||||
|
||||
it("runtimeHandle.id takes precedence over session.id", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = iterm2Plugin.create();
|
||||
await terminal.openSession(
|
||||
makeSession({
|
||||
id: "app-1",
|
||||
runtimeHandle: { id: "tmux-session-42", runtimeName: "tmux", data: {} },
|
||||
}),
|
||||
);
|
||||
|
||||
const findScript = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(findScript).toContain("tmux-session-42");
|
||||
expect(findScript).not.toContain('"app-1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSessionOpen no side effects", () => {
|
||||
it("script contains no select commands", async () => {
|
||||
simulateOsascript("FOUND\n");
|
||||
const terminal = iterm2Plugin.create();
|
||||
await terminal.isSessionOpen!(makeSession({ id: "check-only" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).not.toContain("select aWindow");
|
||||
expect(script).not.toContain("select aTab");
|
||||
// It should still check name
|
||||
expect(script).toContain("name of aSession");
|
||||
expect(script).toContain('"check-only"');
|
||||
});
|
||||
|
||||
it("returns true when FOUND", async () => {
|
||||
simulateOsascript("FOUND\n");
|
||||
const terminal = iterm2Plugin.create();
|
||||
const result = await terminal.isSessionOpen!(makeSession());
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when NOT_FOUND", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = iterm2Plugin.create();
|
||||
const result = await terminal.isSessionOpen!(makeSession());
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when osascript throws", async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
|
||||
cb(new Error("script error"), "");
|
||||
},
|
||||
);
|
||||
const terminal = iterm2Plugin.create();
|
||||
const result = await terminal.isSessionOpen!(makeSession());
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform guard", () => {
|
||||
it("openSession is no-op on non-macOS and warns", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const terminal = iterm2Plugin.create();
|
||||
await terminal.openSession(makeSession());
|
||||
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("only available on macOS"));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("openAll is no-op on non-macOS", async () => {
|
||||
mockPlatform.mockReturnValue("win32");
|
||||
const terminal = iterm2Plugin.create();
|
||||
await terminal.openAll([makeSession()]);
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("isSessionOpen returns false on non-macOS", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const terminal = iterm2Plugin.create();
|
||||
const result = await terminal.isSessionOpen!(makeSession());
|
||||
expect(result).toBe(false);
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openAll full pipeline", () => {
|
||||
it("opens tabs for each session with delay between operations", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = iterm2Plugin.create();
|
||||
const sessions = [
|
||||
makeSession({ id: "s1" }),
|
||||
makeSession({ id: "s2" }),
|
||||
];
|
||||
|
||||
const promise = terminal.openAll(sessions);
|
||||
|
||||
// Process first session
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
// Process second session
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await promise;
|
||||
|
||||
// 2 sessions * 2 calls each (find + open) = 4
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("skips existing sessions and opens only new ones", async () => {
|
||||
simulateOsascriptSequence(["FOUND\n", "NOT_FOUND\n", ""]);
|
||||
const terminal = iterm2Plugin.create();
|
||||
const sessions = [
|
||||
makeSession({ id: "existing" }),
|
||||
makeSession({ id: "new-one" }),
|
||||
];
|
||||
|
||||
const promise = terminal.openAll(sessions);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await promise;
|
||||
|
||||
// existing: 1 call (find=FOUND), new-one: 2 calls (find + open)
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Integration tests for terminal-web.
|
||||
*
|
||||
* No I/O mocking needed — this plugin has no external I/O.
|
||||
* Tests verify state tracking, URL construction, and log output.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import webPlugin from "@agent-orchestrator/plugin-terminal-web";
|
||||
import { makeSession } from "./helpers/event-factory.js";
|
||||
|
||||
describe("terminal-web integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("state tracking", () => {
|
||||
it("isSessionOpen returns false before openSession, true after", async () => {
|
||||
const terminal = webPlugin.create();
|
||||
const session = makeSession({ id: "track-1" });
|
||||
|
||||
expect(await terminal.isSessionOpen!(session)).toBe(false);
|
||||
await terminal.openSession(session);
|
||||
expect(await terminal.isSessionOpen!(session)).toBe(true);
|
||||
});
|
||||
|
||||
it("openAll marks all sessions as open", async () => {
|
||||
const terminal = webPlugin.create();
|
||||
const sessions = [
|
||||
makeSession({ id: "a" }),
|
||||
makeSession({ id: "b" }),
|
||||
makeSession({ id: "c" }),
|
||||
];
|
||||
|
||||
await terminal.openAll(sessions);
|
||||
|
||||
for (const s of sessions) {
|
||||
expect(await terminal.isSessionOpen!(s)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks sessions independently across openSession and openAll", async () => {
|
||||
const terminal = webPlugin.create();
|
||||
const single = makeSession({ id: "single" });
|
||||
const batchA = makeSession({ id: "batch-a" });
|
||||
const batchB = makeSession({ id: "batch-b" });
|
||||
const never = makeSession({ id: "never" });
|
||||
|
||||
await terminal.openSession(single);
|
||||
await terminal.openAll([batchA, batchB]);
|
||||
|
||||
expect(await terminal.isSessionOpen!(single)).toBe(true);
|
||||
expect(await terminal.isSessionOpen!(batchA)).toBe(true);
|
||||
expect(await terminal.isSessionOpen!(batchB)).toBe(true);
|
||||
expect(await terminal.isSessionOpen!(never)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL construction", () => {
|
||||
it("default dashboard URL uses port 3000", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const terminal = webPlugin.create();
|
||||
await terminal.openSession(makeSession({ id: "url-test" }));
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("http://localhost:3000/sessions/url-test/terminal"),
|
||||
);
|
||||
});
|
||||
|
||||
it("custom dashboard URL is used in log output", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const terminal = webPlugin.create({ dashboardUrl: "https://my-dash.io:8080" });
|
||||
await terminal.openSession(makeSession({ id: "custom-url" }));
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://my-dash.io:8080/sessions/custom-url/terminal"),
|
||||
);
|
||||
});
|
||||
|
||||
it("openAll logs total count and dashboard URL", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const terminal = webPlugin.create({ dashboardUrl: "https://dashboard.io" });
|
||||
await terminal.openAll([
|
||||
makeSession({ id: "x" }),
|
||||
makeSession({ id: "y" }),
|
||||
]);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("2 sessions"),
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://dashboard.io/sessions"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty sessions", () => {
|
||||
it("openAll handles empty array", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const terminal = webPlugin.create();
|
||||
await terminal.openAll([]);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("0 sessions"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,11 +5,6 @@ export default defineConfig({
|
|||
testTimeout: 120_000,
|
||||
hookTimeout: 60_000,
|
||||
pool: "forks",
|
||||
poolOptions: {
|
||||
forks: {
|
||||
singleFork: true,
|
||||
},
|
||||
},
|
||||
include: ["src/**/*.integration.test.ts"],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "@agent-orchestrator/plugin-notifier-composio",
|
||||
"version": "0.1.0",
|
||||
"description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agent-orchestrator/core": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"composio-core": ">=0.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"composio-core": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { OrchestratorEvent, NotifyAction } from "@agent-orchestrator/core";
|
||||
import { manifest, create } from "./index.js";
|
||||
|
||||
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
type: "session.spawned",
|
||||
priority: "info",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-project",
|
||||
timestamp: new Date("2025-06-15T12:00:00Z"),
|
||||
message: "Session app-1 spawned successfully",
|
||||
data: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mockExecuteAction = vi.fn().mockResolvedValue({ successful: true });
|
||||
|
||||
vi.mock("composio-core", () => {
|
||||
// Must use a regular function (not arrow) to be callable with `new`
|
||||
function MockComposio() {
|
||||
return { executeAction: mockExecuteAction };
|
||||
}
|
||||
return { Composio: MockComposio };
|
||||
});
|
||||
|
||||
describe("notifier-composio", () => {
|
||||
const originalEnv = process.env.COMPOSIO_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.COMPOSIO_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.COMPOSIO_API_KEY = originalEnv;
|
||||
} else {
|
||||
delete process.env.COMPOSIO_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("composio");
|
||||
expect(manifest.slot).toBe("notifier");
|
||||
});
|
||||
|
||||
it("has a version", () => {
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create — config parsing", () => {
|
||||
it("reads apiKey from config", () => {
|
||||
const notifier = create({ composioApiKey: "test-key" });
|
||||
expect(notifier.name).toBe("composio");
|
||||
});
|
||||
|
||||
it("reads apiKey from COMPOSIO_API_KEY env var", () => {
|
||||
process.env.COMPOSIO_API_KEY = "env-key";
|
||||
const notifier = create();
|
||||
expect(notifier.name).toBe("composio");
|
||||
});
|
||||
|
||||
it("throws on invalid defaultApp", () => {
|
||||
expect(() => create({ composioApiKey: "k", defaultApp: "telegram" })).toThrow(
|
||||
'Invalid defaultApp: "telegram"',
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts slack as defaultApp", () => {
|
||||
expect(() => create({ composioApiKey: "k", defaultApp: "slack" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts discord as defaultApp", () => {
|
||||
expect(() => create({ composioApiKey: "k", defaultApp: "discord" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts gmail as defaultApp with emailTo", () => {
|
||||
expect(() => create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "a@b.com" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when gmail is defaultApp without emailTo", () => {
|
||||
expect(() => create({ composioApiKey: "k", defaultApp: "gmail" })).toThrow(
|
||||
"emailTo is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to slack when defaultApp not specified", async () => {
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "SLACK_SEND_MESSAGE",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify", () => {
|
||||
it("calls SLACK_SEND_MESSAGE for slack app", async () => {
|
||||
const notifier = create({ composioApiKey: "k", defaultApp: "slack" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "SLACK_SEND_MESSAGE",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls DISCORD_SEND_MESSAGE for discord app", async () => {
|
||||
const notifier = create({ composioApiKey: "k", defaultApp: "discord" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "DISCORD_SEND_MESSAGE",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls GMAIL_SEND_EMAIL for gmail app", async () => {
|
||||
const notifier = create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "test@test.com" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "GMAIL_SEND_EMAIL",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes to channelId when set", async () => {
|
||||
const notifier = create({ composioApiKey: "k", channelId: "C123" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.channel).toBe("C123");
|
||||
});
|
||||
|
||||
it("routes to channelName when channelId not set", async () => {
|
||||
const notifier = create({ composioApiKey: "k", channelName: "#general" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.channel).toBe("#general");
|
||||
});
|
||||
|
||||
it("includes priority emoji in text", async () => {
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.text).toContain("\u{1F6A8}");
|
||||
});
|
||||
|
||||
it("includes prUrl when present as string", async () => {
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/pull/1" } }));
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.text).toContain("https://github.com/pull/1");
|
||||
});
|
||||
|
||||
it("ignores prUrl when not a string", async () => {
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
await notifier.notify(makeEvent({ data: { prUrl: 42 } }));
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.text).not.toContain("PR:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyWithActions", () => {
|
||||
it("includes action labels in text", async () => {
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Merge", url: "https://github.com/merge" },
|
||||
{ label: "Kill", callbackEndpoint: "/api/kill" },
|
||||
];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.text).toContain("Merge");
|
||||
expect(callArgs.params.text).toContain("Kill");
|
||||
});
|
||||
|
||||
it("includes URL actions as links", async () => {
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "View PR", url: "https://github.com/pull/42" },
|
||||
];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.text).toContain("https://github.com/pull/42");
|
||||
});
|
||||
|
||||
it("renders callback-only actions without URL", async () => {
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Restart", callbackEndpoint: "/api/restart" },
|
||||
];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.text).toContain("- Restart");
|
||||
});
|
||||
|
||||
it("uses correct tool slug for configured app", async () => {
|
||||
const notifier = create({ composioApiKey: "k", defaultApp: "discord" });
|
||||
const actions: NotifyAction[] = [{ label: "Test", url: "https://example.com" }];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
expect(mockExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "DISCORD_SEND_MESSAGE",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("post", () => {
|
||||
it("sends text payload", async () => {
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
await notifier.post!("Hello from AO");
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.text).toBe("Hello from AO");
|
||||
});
|
||||
|
||||
it("overrides channel from context", async () => {
|
||||
const notifier = create({ composioApiKey: "k", channelName: "#default" });
|
||||
await notifier.post!("test", { channel: "#override" });
|
||||
|
||||
const callArgs = mockExecuteAction.mock.calls[0][0];
|
||||
expect(callArgs.params.channel).toBe("#override");
|
||||
});
|
||||
|
||||
it("returns null", async () => {
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
const result = await notifier.post!("test");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws when SDK returns unsuccessful result", async () => {
|
||||
mockExecuteAction.mockResolvedValueOnce({
|
||||
successful: false,
|
||||
error: "channel not found",
|
||||
});
|
||||
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("channel not found");
|
||||
});
|
||||
|
||||
it("wraps SDK error with descriptive message", async () => {
|
||||
mockExecuteAction.mockResolvedValueOnce({
|
||||
successful: false,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const notifier = create({ composioApiKey: "k" });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow(
|
||||
"unknown error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-op when no apiKey", () => {
|
||||
it("does nothing when no api key", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent());
|
||||
expect(mockExecuteAction).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("No composioApiKey"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
import type {
|
||||
PluginModule,
|
||||
Notifier,
|
||||
OrchestratorEvent,
|
||||
NotifyAction,
|
||||
NotifyContext,
|
||||
EventPriority,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
export const manifest = {
|
||||
name: "composio",
|
||||
slot: "notifier" as const,
|
||||
description: "Notifier plugin: Composio unified notifications (Slack, Discord, email)",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
const PRIORITY_EMOJI: Record<EventPriority, string> = {
|
||||
urgent: "\u{1F6A8}",
|
||||
action: "\u{1F449}",
|
||||
warning: "\u{26A0}\u{FE0F}",
|
||||
info: "\u{2139}\u{FE0F}",
|
||||
};
|
||||
|
||||
type ComposioApp = "slack" | "discord" | "gmail";
|
||||
|
||||
const APP_TOOL_SLUG: Record<ComposioApp, string> = {
|
||||
slack: "SLACK_SEND_MESSAGE",
|
||||
discord: "DISCORD_SEND_MESSAGE",
|
||||
gmail: "GMAIL_SEND_EMAIL",
|
||||
};
|
||||
|
||||
const VALID_APPS = new Set<string>(["slack", "discord", "gmail"]);
|
||||
|
||||
const GMAIL_SUBJECT = "Agent Orchestrator Notification";
|
||||
|
||||
interface ComposioToolkit {
|
||||
executeAction(params: {
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
entityId?: string;
|
||||
}): Promise<{ successful: boolean; data?: unknown; error?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load composio-core SDK.
|
||||
* Returns null if the package is not installed.
|
||||
*
|
||||
* We use dynamic import + unknown casting because composio-core is an
|
||||
* optional peer dependency — it may or may not be installed, and its
|
||||
* TypeScript types may not match our internal interface exactly.
|
||||
*/
|
||||
async function loadComposioSDK(
|
||||
apiKey: string,
|
||||
): Promise<ComposioToolkit | null> {
|
||||
try {
|
||||
// String literal import so vitest can intercept it for mocking.
|
||||
// The `as unknown as …` cast is safe because we validate the shape below.
|
||||
const mod = (await import("composio-core")) as unknown as Record<string, unknown>;
|
||||
const ComposioClass = (mod.Composio ??
|
||||
(mod.default as Record<string, unknown> | undefined)?.Composio ??
|
||||
mod.default) as (new (opts: { apiKey: string }) => unknown) | undefined;
|
||||
if (typeof ComposioClass !== "function") {
|
||||
throw new Error("Could not find Composio class in composio-core module");
|
||||
}
|
||||
const client = new ComposioClass({ apiKey });
|
||||
return client as ComposioToolkit;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
|
||||
if (
|
||||
message.includes("Cannot find module") ||
|
||||
message.includes("Cannot find package") ||
|
||||
message.includes("MODULE_NOT_FOUND") ||
|
||||
code === "ERR_MODULE_NOT_FOUND"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function formatNotifyText(event: OrchestratorEvent): string {
|
||||
const emoji = PRIORITY_EMOJI[event.priority];
|
||||
const parts = [
|
||||
`${emoji} *${event.type}* — ${event.sessionId}`,
|
||||
event.message,
|
||||
];
|
||||
|
||||
const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined;
|
||||
if (prUrl) {
|
||||
parts.push(`PR: ${prUrl}`);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatActionsText(event: OrchestratorEvent, actions: NotifyAction[]): string {
|
||||
const base = formatNotifyText(event);
|
||||
const actionLines = actions.map((a) => {
|
||||
if (a.url) return `- ${a.label}: ${a.url}`;
|
||||
return `- ${a.label}`;
|
||||
});
|
||||
|
||||
return `${base}\n\nActions:\n${actionLines.join("\n")}`;
|
||||
}
|
||||
|
||||
function buildToolArgs(
|
||||
app: ComposioApp,
|
||||
text: string,
|
||||
channelId?: string,
|
||||
channelName?: string,
|
||||
emailTo?: string,
|
||||
): Record<string, unknown> {
|
||||
if (app === "slack") {
|
||||
const args: Record<string, unknown> = { text };
|
||||
if (channelId) args.channel = channelId;
|
||||
else if (channelName) args.channel = channelName;
|
||||
return args;
|
||||
}
|
||||
|
||||
if (app === "discord") {
|
||||
const args: Record<string, unknown> = { content: text };
|
||||
// Discord requires numeric channel IDs — channelName is not supported
|
||||
if (channelId) args.channel_id = channelId;
|
||||
else if (channelName) args.channel_id = channelName;
|
||||
return args;
|
||||
}
|
||||
|
||||
// gmail — emailTo is required, validated at config time
|
||||
return {
|
||||
to: emailTo ?? "",
|
||||
subject: GMAIL_SUBJECT,
|
||||
body: text,
|
||||
};
|
||||
}
|
||||
|
||||
export function create(config?: Record<string, unknown>): Notifier {
|
||||
const apiKey =
|
||||
(typeof config?.composioApiKey === "string" ? config.composioApiKey : undefined) ??
|
||||
process.env.COMPOSIO_API_KEY;
|
||||
const defaultApp: ComposioApp =
|
||||
typeof config?.defaultApp === "string" && VALID_APPS.has(config.defaultApp)
|
||||
? (config.defaultApp as ComposioApp)
|
||||
: "slack";
|
||||
const channelName = typeof config?.channelName === "string" ? config.channelName : undefined;
|
||||
const channelId = typeof config?.channelId === "string" ? config.channelId : undefined;
|
||||
const emailTo = typeof config?.emailTo === "string" ? config.emailTo : undefined;
|
||||
|
||||
// Internal: allows tests to inject a mock client without mocking composio-core
|
||||
const clientOverride =
|
||||
config?._clientOverride !== undefined &&
|
||||
config._clientOverride !== null &&
|
||||
typeof (config._clientOverride as ComposioToolkit).executeAction === "function"
|
||||
? (config._clientOverride as ComposioToolkit)
|
||||
: undefined;
|
||||
|
||||
if (typeof config?.defaultApp === "string" && !VALID_APPS.has(config.defaultApp)) {
|
||||
throw new Error(
|
||||
`[notifier-composio] Invalid defaultApp: "${config.defaultApp}". Must be one of: slack, discord, gmail`,
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultApp === "gmail" && !emailTo) {
|
||||
throw new Error(
|
||||
"[notifier-composio] emailTo is required when defaultApp is \"gmail\"",
|
||||
);
|
||||
}
|
||||
|
||||
let client: ComposioToolkit | null | undefined = clientOverride;
|
||||
let warnedNoKey = false;
|
||||
let sdkMissing = false;
|
||||
|
||||
async function getClient(): Promise<ComposioToolkit | null> {
|
||||
// If a client override was injected, always use it
|
||||
if (clientOverride) return clientOverride;
|
||||
|
||||
if (!apiKey) {
|
||||
if (!warnedNoKey) {
|
||||
console.warn(
|
||||
"[notifier-composio] No composioApiKey or COMPOSIO_API_KEY configured — notifications will be no-ops",
|
||||
);
|
||||
warnedNoKey = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sdkMissing) return null;
|
||||
|
||||
if (client === undefined) {
|
||||
client = await loadComposioSDK(apiKey);
|
||||
if (client === null) {
|
||||
sdkMissing = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[notifier-composio] composio-core package is not installed — notifications will be no-ops. Run: npm install composio-core",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async function executeWithTimeout(
|
||||
composio: ComposioToolkit,
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const timeoutMs = 30_000;
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
|
||||
const actionPromise = composio.executeAction({ action, params });
|
||||
// Prevent unhandled rejection if the timeout fires and actionPromise later rejects
|
||||
actionPromise.catch(() => {});
|
||||
|
||||
const result = await Promise.race([
|
||||
actionPromise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutSignal.addEventListener("abort", () => {
|
||||
reject(new Error(`[notifier-composio] Composio API call timed out after ${timeoutMs / 1000}s`));
|
||||
}, { once: true });
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!result.successful) {
|
||||
throw new Error(
|
||||
`[notifier-composio] Composio action ${action} failed: ${result.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "composio",
|
||||
|
||||
async notify(event: OrchestratorEvent): Promise<void> {
|
||||
const composio = await getClient();
|
||||
if (!composio) return;
|
||||
|
||||
const text = formatNotifyText(event);
|
||||
const toolSlug = APP_TOOL_SLUG[defaultApp];
|
||||
const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo);
|
||||
|
||||
await executeWithTimeout(composio, toolSlug, args);
|
||||
},
|
||||
|
||||
async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void> {
|
||||
const composio = await getClient();
|
||||
if (!composio) return;
|
||||
|
||||
const text = formatActionsText(event, actions);
|
||||
const toolSlug = APP_TOOL_SLUG[defaultApp];
|
||||
const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo);
|
||||
|
||||
await executeWithTimeout(composio, toolSlug, args);
|
||||
},
|
||||
|
||||
async post(message: string, context?: NotifyContext): Promise<string | null> {
|
||||
const composio = await getClient();
|
||||
if (!composio) return null;
|
||||
|
||||
const channel = context?.channel ?? channelId ?? channelName;
|
||||
const toolSlug = APP_TOOL_SLUG[defaultApp];
|
||||
|
||||
const args: Record<string, unknown> = defaultApp === "gmail"
|
||||
? { to: emailTo ?? "", subject: GMAIL_SUBJECT, body: message }
|
||||
: defaultApp === "discord"
|
||||
? { content: message, ...(channel ? { channel_id: channel } : {}) }
|
||||
: { text: message, ...(channel ? { channel } : {}) };
|
||||
|
||||
await executeWithTimeout(composio, toolSlug, args);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Notifier>;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||
import type { OrchestratorEvent, NotifyAction } from "@agent-orchestrator/core";
|
||||
|
||||
// Mock node:child_process
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:os
|
||||
vi.mock("node:os", () => ({
|
||||
platform: vi.fn(() => "darwin"),
|
||||
}));
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { manifest, create, escapeAppleScript } from "./index.js";
|
||||
|
||||
const mockExecFile = execFile as unknown as Mock;
|
||||
const mockPlatform = platform as unknown as Mock;
|
||||
|
||||
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
type: "session.spawned",
|
||||
priority: "info",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-project",
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
message: "Session app-1 spawned",
|
||||
data: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("notifier-desktop", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPlatform.mockReturnValue("darwin");
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null) => void) => {
|
||||
cb(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("desktop");
|
||||
expect(manifest.slot).toBe("notifier");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeAppleScript", () => {
|
||||
it("escapes double quotes", () => {
|
||||
expect(escapeAppleScript('hello "world"')).toBe('hello \\"world\\"');
|
||||
});
|
||||
|
||||
it("escapes backslashes", () => {
|
||||
expect(escapeAppleScript("path\\to\\file")).toBe("path\\\\to\\\\file");
|
||||
});
|
||||
|
||||
it("escapes both backslashes and quotes", () => {
|
||||
expect(escapeAppleScript('say \\"hi\\"')).toBe('say \\\\\\"hi\\\\\\"');
|
||||
});
|
||||
|
||||
it("returns plain strings unchanged", () => {
|
||||
expect(escapeAppleScript("hello world")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a notifier with name 'desktop'", () => {
|
||||
const notifier = create();
|
||||
expect(notifier.name).toBe("desktop");
|
||||
});
|
||||
|
||||
it("has notify and notifyWithActions methods", () => {
|
||||
const notifier = create();
|
||||
expect(typeof notifier.notify).toBe("function");
|
||||
expect(typeof notifier.notifyWithActions).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify", () => {
|
||||
it("calls osascript on macOS", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledOnce();
|
||||
expect(mockExecFile.mock.calls[0][0]).toBe("osascript");
|
||||
expect(mockExecFile.mock.calls[0][1][0]).toBe("-e");
|
||||
});
|
||||
|
||||
it("includes session ID in title", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ sessionId: "backend-5" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain("backend-5");
|
||||
});
|
||||
|
||||
it("includes event message in notification body", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ message: "CI is failing" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain("CI is failing");
|
||||
});
|
||||
|
||||
it("uses URGENT prefix for urgent priority", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain("URGENT");
|
||||
});
|
||||
|
||||
it("uses 'Agent Orchestrator' prefix for non-urgent priority", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ priority: "action" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain("Agent Orchestrator");
|
||||
});
|
||||
|
||||
it("includes sound for urgent notifications", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain('sound name "default"');
|
||||
});
|
||||
|
||||
it("does not include sound for info notifications", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ priority: "info" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).not.toContain("sound name");
|
||||
});
|
||||
|
||||
it("does not include sound for action notifications", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ priority: "action" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).not.toContain("sound name");
|
||||
});
|
||||
|
||||
it("does not include sound for warning notifications", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ priority: "warning" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).not.toContain("sound name");
|
||||
});
|
||||
|
||||
it("respects sound=false config even for urgent", async () => {
|
||||
const notifier = create({ sound: false });
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).not.toContain("sound name");
|
||||
});
|
||||
|
||||
it("escapes special characters in title and message", async () => {
|
||||
const notifier = create();
|
||||
await notifier.notify(
|
||||
makeEvent({ sessionId: 'test"inject', message: 'msg with "quotes" and \\backslash' }),
|
||||
);
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
// Should not contain unescaped quotes (other than the AppleScript string delimiters)
|
||||
expect(script).toContain('test\\"inject');
|
||||
expect(script).toContain('\\"quotes\\"');
|
||||
expect(script).toContain("\\\\backslash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify on Linux", () => {
|
||||
it("calls notify-send on Linux", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledOnce();
|
||||
expect(mockExecFile.mock.calls[0][0]).toBe("notify-send");
|
||||
});
|
||||
|
||||
it("includes --urgency=critical for urgent on Linux", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain("--urgency=critical");
|
||||
// Options must come before title/message for notify-send
|
||||
const urgencyIdx = args.indexOf("--urgency=critical");
|
||||
const titleIdx = args.findIndex((a: string) => a.includes("URGENT"));
|
||||
expect(urgencyIdx).toBeLessThan(titleIdx);
|
||||
});
|
||||
|
||||
it("includes --urgency=critical for urgent even when sound is disabled", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const notifier = create({ sound: false });
|
||||
await notifier.notify(makeEvent({ priority: "urgent" }));
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain("--urgency=critical");
|
||||
});
|
||||
|
||||
it("does not include --urgency=critical for info on Linux", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent({ priority: "info" }));
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).not.toContain("--urgency=critical");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify on unsupported platform", () => {
|
||||
it("resolves without error on unsupported platform", async () => {
|
||||
mockPlatform.mockReturnValue("win32");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const notifier = create();
|
||||
await expect(notifier.notify(makeEvent())).resolves.toBeUndefined();
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not supported on win32"));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyWithActions", () => {
|
||||
it("includes action labels in the message", async () => {
|
||||
const notifier = create();
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Merge", url: "https://github.com/pr/1" },
|
||||
{ label: "Kill", callbackEndpoint: "/api/kill" },
|
||||
];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain("Merge");
|
||||
expect(script).toContain("Kill");
|
||||
});
|
||||
|
||||
it("includes sound for urgent with actions", async () => {
|
||||
const notifier = create();
|
||||
const actions: NotifyAction[] = [{ label: "Fix", url: "https://example.com" }];
|
||||
await notifier.notifyWithActions!(makeEvent({ priority: "urgent" }), actions);
|
||||
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).toContain('sound name "default"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("rejects when execFile fails", async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null) => void) => {
|
||||
cb(new Error("osascript not found"));
|
||||
},
|
||||
);
|
||||
const notifier = create();
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("osascript not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1,116 @@
|
|||
// notifier-desktop plugin — to be implemented
|
||||
import { execFile } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import {
|
||||
escapeAppleScript,
|
||||
type PluginModule,
|
||||
type Notifier,
|
||||
type OrchestratorEvent,
|
||||
type NotifyAction,
|
||||
type EventPriority,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
export const manifest = {
|
||||
name: "desktop",
|
||||
slot: "notifier" as const,
|
||||
description: "Notifier plugin: OS desktop notifications",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { escapeAppleScript } from "@agent-orchestrator/core";
|
||||
|
||||
/**
|
||||
* Map event priority to notification urgency:
|
||||
* - urgent: sound alert
|
||||
* - action: normal notification
|
||||
* - info/warning: silent
|
||||
*/
|
||||
function shouldPlaySound(priority: EventPriority, soundEnabled: boolean): boolean {
|
||||
if (!soundEnabled) return false;
|
||||
return priority === "urgent";
|
||||
}
|
||||
|
||||
function formatTitle(event: OrchestratorEvent): string {
|
||||
const prefix = event.priority === "urgent" ? "URGENT" : "Agent Orchestrator";
|
||||
return `${prefix} [${event.sessionId}]`;
|
||||
}
|
||||
|
||||
function formatMessage(event: OrchestratorEvent): string {
|
||||
return event.message;
|
||||
}
|
||||
|
||||
function formatActionsMessage(event: OrchestratorEvent, actions: NotifyAction[]): string {
|
||||
const actionLabels = actions.map((a) => a.label).join(" | ");
|
||||
return `${event.message}\n\nActions: ${actionLabels}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a desktop notification using osascript (macOS) or notify-send (Linux).
|
||||
* Falls back gracefully if neither is available.
|
||||
*
|
||||
* Note: Desktop notifications do not support click-through URLs natively.
|
||||
* On macOS, osascript's `display notification` lacks URL support.
|
||||
* Consider `terminal-notifier` for click-to-open if needed in the future.
|
||||
*/
|
||||
function sendNotification(
|
||||
title: string,
|
||||
message: string,
|
||||
options: { sound: boolean; isUrgent: boolean },
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const os = platform();
|
||||
|
||||
if (os === "darwin") {
|
||||
const safeTitle = escapeAppleScript(title);
|
||||
const safeMessage = escapeAppleScript(message);
|
||||
const soundClause = options.sound ? ' sound name "default"' : "";
|
||||
const script = `display notification "${safeMessage}" with title "${safeTitle}"${soundClause}`;
|
||||
execFile("osascript", ["-e", script], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
} else if (os === "linux") {
|
||||
// Linux urgency is driven by event priority, not the macOS sound config
|
||||
const args: string[] = [];
|
||||
if (options.isUrgent) {
|
||||
args.push("--urgency=critical");
|
||||
}
|
||||
args.push(title, message);
|
||||
execFile("notify-send", args, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
} else {
|
||||
console.warn(`[notifier-desktop] Desktop notifications not supported on ${os}`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function create(config?: Record<string, unknown>): Notifier {
|
||||
const soundEnabled = typeof config?.sound === "boolean" ? config.sound : true;
|
||||
|
||||
return {
|
||||
name: "desktop",
|
||||
|
||||
async notify(event: OrchestratorEvent): Promise<void> {
|
||||
const title = formatTitle(event);
|
||||
const message = formatMessage(event);
|
||||
const sound = shouldPlaySound(event.priority, soundEnabled);
|
||||
const isUrgent = event.priority === "urgent";
|
||||
await sendNotification(title, message, { sound, isUrgent });
|
||||
},
|
||||
|
||||
async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void> {
|
||||
// Desktop notifications cannot display interactive action buttons.
|
||||
// Actions are rendered as text labels in the notification body as a fallback.
|
||||
const title = formatTitle(event);
|
||||
const message = formatActionsMessage(event, actions);
|
||||
const sound = shouldPlaySound(event.priority, soundEnabled);
|
||||
const isUrgent = event.priority === "urgent";
|
||||
await sendNotification(title, message, { sound, isUrgent });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Notifier>;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,388 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OrchestratorEvent, NotifyAction, EventPriority } from "@agent-orchestrator/core";
|
||||
import { manifest, create } from "./index.js";
|
||||
|
||||
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
type: "session.spawned",
|
||||
priority: "info",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-project",
|
||||
timestamp: new Date("2025-06-15T12:00:00Z"),
|
||||
message: "Session app-1 spawned successfully",
|
||||
data: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetchOk() {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: () => Promise.resolve("ok"),
|
||||
});
|
||||
}
|
||||
|
||||
describe("notifier-slack", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("slack");
|
||||
expect(manifest.slot).toBe("notifier");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a notifier with name 'slack'", () => {
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
expect(notifier.name).toBe("slack");
|
||||
});
|
||||
|
||||
it("warns when no webhookUrl configured", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
create();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No webhookUrl configured"));
|
||||
});
|
||||
|
||||
it("throws on invalid URL scheme", () => {
|
||||
expect(() => create({ webhookUrl: "file:///etc/passwd" })).toThrow("must be http(s)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify", () => {
|
||||
it("does nothing when no webhookUrl", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POSTs to the webhook URL", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("https://hooks.slack.com/test");
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe("POST");
|
||||
});
|
||||
|
||||
it("sends JSON with Content-Type header", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const opts = fetchMock.mock.calls[0][1];
|
||||
expect(opts.headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("includes username in payload", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.username).toBe("Agent Orchestrator");
|
||||
});
|
||||
|
||||
it("uses custom username when configured", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
webhookUrl: "https://hooks.slack.com/test",
|
||||
username: "MyBot",
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.username).toBe("MyBot");
|
||||
});
|
||||
|
||||
it("includes channel when configured", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
webhookUrl: "https://hooks.slack.com/test",
|
||||
channel: "#deploys",
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.channel).toBe("#deploys");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("server error"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow(
|
||||
"Slack webhook failed (500): server error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Block Kit formatting", () => {
|
||||
it("includes header block with priority emoji and session ID", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ priority: "urgent", sessionId: "backend-3" }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const header = body.blocks[0];
|
||||
expect(header.type).toBe("header");
|
||||
expect(header.text.type).toBe("plain_text");
|
||||
expect(header.text.text).toContain(":rotating_light:");
|
||||
expect(header.text.text).toContain("backend-3");
|
||||
});
|
||||
|
||||
it("uses correct emoji for each priority level", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
|
||||
const priorities: Array<[EventPriority, string]> = [
|
||||
["urgent", ":rotating_light:"],
|
||||
["action", ":point_right:"],
|
||||
["warning", ":warning:"],
|
||||
["info", ":information_source:"],
|
||||
];
|
||||
|
||||
for (const [priority, emoji] of priorities) {
|
||||
fetchMock.mockClear();
|
||||
await notifier.notify(makeEvent({ priority }));
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.blocks[0].text.text).toContain(emoji);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes section block with event message", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ message: "CI is green" }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const section = body.blocks[1];
|
||||
expect(section.type).toBe("section");
|
||||
expect(section.text.text).toBe("CI is green");
|
||||
});
|
||||
|
||||
it("includes context block with project and priority", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ projectId: "frontend", priority: "action" }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const context = body.blocks[2];
|
||||
expect(context.type).toBe("context");
|
||||
expect(context.elements[0].text).toContain("*Project:* frontend");
|
||||
expect(context.elements[0].text).toContain("*Priority:* action");
|
||||
});
|
||||
|
||||
it("includes PR link when prUrl is a string in event data", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/42" } }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const prBlock = body.blocks.find(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.type === "section" && (b as any).text?.text?.includes("View Pull Request"),
|
||||
);
|
||||
expect(prBlock).toBeDefined();
|
||||
expect(prBlock.text.text).toContain("https://github.com/org/repo/pull/42");
|
||||
});
|
||||
|
||||
it("ignores prUrl when it is not a string", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ data: { prUrl: 12345 } }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const prBlock = body.blocks.find(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.type === "section" && (b as any).text?.text?.includes("View Pull Request"),
|
||||
);
|
||||
expect(prBlock).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores ciStatus when it is not a string", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ data: { ciStatus: { nested: true } } }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const ciBlock = body.blocks.find(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"),
|
||||
);
|
||||
expect(ciBlock).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes CI status when ciStatus is a string", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ data: { ciStatus: "passing" } }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const ciBlock = body.blocks.find(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"),
|
||||
);
|
||||
expect(ciBlock).toBeDefined();
|
||||
expect(ciBlock.elements[0].text).toContain(":white_check_mark:");
|
||||
});
|
||||
|
||||
it("uses :x: emoji for failing CI", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent({ data: { ciStatus: "failing" } }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const ciBlock = body.blocks.find(
|
||||
(b: Record<string, unknown>) =>
|
||||
b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"),
|
||||
);
|
||||
expect(ciBlock.elements[0].text).toContain(":x:");
|
||||
});
|
||||
|
||||
it("ends with a divider block", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const lastBlock = body.blocks[body.blocks.length - 1];
|
||||
expect(lastBlock.type).toBe("divider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyWithActions", () => {
|
||||
it("includes action buttons with URLs", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Merge", url: "https://github.com/org/repo/pull/42/merge" },
|
||||
{ label: "Open", url: "https://github.com/org/repo/pull/42" },
|
||||
];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const actionsBlock = body.blocks.find((b: Record<string, unknown>) => b.type === "actions");
|
||||
expect(actionsBlock).toBeDefined();
|
||||
expect(actionsBlock.elements).toHaveLength(2);
|
||||
expect(actionsBlock.elements[0].type).toBe("button");
|
||||
expect(actionsBlock.elements[0].text.text).toBe("Merge");
|
||||
});
|
||||
|
||||
it("includes callback-based action buttons", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Kill Session", callbackEndpoint: "/api/sessions/app-1/kill" },
|
||||
];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const actionsBlock = body.blocks.find((b: Record<string, unknown>) => b.type === "actions");
|
||||
expect(actionsBlock.elements[0].action_id).toBe("ao_kill_session_0");
|
||||
expect(actionsBlock.elements[0].value).toBe("/api/sessions/app-1/kill");
|
||||
});
|
||||
|
||||
it("filters out actions with no url or callback", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "No-op" },
|
||||
{ label: "Merge", url: "https://example.com" },
|
||||
];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const actionsBlock = body.blocks.find((b: Record<string, unknown>) => b.type === "actions");
|
||||
expect(actionsBlock.elements).toHaveLength(1);
|
||||
expect(actionsBlock.elements[0].text.text).toBe("Merge");
|
||||
});
|
||||
});
|
||||
|
||||
describe("post", () => {
|
||||
it("sends a text message", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ webhookUrl: "https://hooks.slack.com/test" });
|
||||
const result = await notifier.post!("Hello from AO");
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.text).toBe("Hello from AO");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses context channel over default", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
webhookUrl: "https://hooks.slack.com/test",
|
||||
channel: "#default",
|
||||
});
|
||||
await notifier.post!("test", { channel: "#override" });
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.channel).toBe("#override");
|
||||
});
|
||||
|
||||
it("returns null when no webhookUrl", async () => {
|
||||
const fetchMock = mockFetchOk();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create();
|
||||
const result = await notifier.post!("test");
|
||||
expect(result).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1,188 @@
|
|||
// notifier-slack plugin — to be implemented
|
||||
import {
|
||||
validateUrl,
|
||||
type PluginModule,
|
||||
type Notifier,
|
||||
type OrchestratorEvent,
|
||||
type NotifyAction,
|
||||
type NotifyContext,
|
||||
type EventPriority,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
export const manifest = {
|
||||
name: "slack",
|
||||
slot: "notifier" as const,
|
||||
description: "Notifier plugin: Slack webhook notifications",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
const PRIORITY_EMOJI: Record<EventPriority, string> = {
|
||||
urgent: ":rotating_light:",
|
||||
action: ":point_right:",
|
||||
warning: ":warning:",
|
||||
info: ":information_source:",
|
||||
};
|
||||
|
||||
function buildBlocks(event: OrchestratorEvent, actions?: NotifyAction[]): unknown[] {
|
||||
const blocks: unknown[] = [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: `${PRIORITY_EMOJI[event.priority]} ${event.type} — ${event.sessionId}`,
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: event.message,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Project:* ${event.projectId} | *Priority:* ${event.priority} | *Time:* <!date^${Math.floor(event.timestamp.getTime() / 1000)}^{date_short_pretty} {time}|${event.timestamp.toISOString()}>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Add PR link if available (type-guarded)
|
||||
const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined;
|
||||
if (prUrl) {
|
||||
blocks.push({
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:github: <${prUrl}|View Pull Request>`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add CI status if available (type-guarded)
|
||||
const ciStatus = typeof event.data.ciStatus === "string" ? event.data.ciStatus : undefined;
|
||||
if (ciStatus) {
|
||||
const ciEmoji = ciStatus === "passing" ? ":white_check_mark:" : ":x:";
|
||||
blocks.push({
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${ciEmoji} CI: ${ciStatus}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Add action buttons
|
||||
if (actions && actions.length > 0) {
|
||||
const elements = actions
|
||||
.filter((a) => a.url || a.callbackEndpoint)
|
||||
.map((action) => {
|
||||
if (action.url) {
|
||||
return {
|
||||
type: "button",
|
||||
text: { type: "plain_text", text: action.label, emoji: true },
|
||||
url: action.url,
|
||||
};
|
||||
}
|
||||
const sanitized = action.label
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_|_$/g, "");
|
||||
const idx = actions.indexOf(action);
|
||||
const actionId = sanitized ? `${sanitized}_${idx}` : `action_${idx}`;
|
||||
return {
|
||||
type: "button",
|
||||
text: { type: "plain_text", text: action.label, emoji: true },
|
||||
action_id: `ao_${actionId}`,
|
||||
value: action.callbackEndpoint,
|
||||
};
|
||||
});
|
||||
|
||||
if (elements.length > 0) {
|
||||
blocks.push({
|
||||
type: "actions",
|
||||
elements,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push({ type: "divider" });
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
async function postToWebhook(webhookUrl: string, payload: Record<string, unknown>): Promise<void> {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Slack webhook failed (${response.status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function create(config?: Record<string, unknown>): Notifier {
|
||||
const webhookUrl = config?.webhookUrl as string | undefined;
|
||||
const defaultChannel = config?.channel as string | undefined;
|
||||
const username = (config?.username as string) ?? "Agent Orchestrator";
|
||||
|
||||
if (!webhookUrl) {
|
||||
console.warn("[notifier-slack] No webhookUrl configured — notifications will be no-ops");
|
||||
} else {
|
||||
validateUrl(webhookUrl, "notifier-slack");
|
||||
}
|
||||
|
||||
return {
|
||||
name: "slack",
|
||||
|
||||
async notify(event: OrchestratorEvent): Promise<void> {
|
||||
if (!webhookUrl) return;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
username,
|
||||
blocks: buildBlocks(event),
|
||||
};
|
||||
if (defaultChannel) payload.channel = defaultChannel;
|
||||
|
||||
await postToWebhook(webhookUrl, payload);
|
||||
},
|
||||
|
||||
async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void> {
|
||||
if (!webhookUrl) return;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
username,
|
||||
blocks: buildBlocks(event, actions),
|
||||
};
|
||||
if (defaultChannel) payload.channel = defaultChannel;
|
||||
|
||||
await postToWebhook(webhookUrl, payload);
|
||||
},
|
||||
|
||||
async post(message: string, context?: NotifyContext): Promise<string | null> {
|
||||
if (!webhookUrl) return null;
|
||||
|
||||
const channel = context?.channel ?? defaultChannel;
|
||||
const payload: Record<string, unknown> = {
|
||||
username,
|
||||
text: message,
|
||||
};
|
||||
if (channel) payload.channel = channel;
|
||||
|
||||
await postToWebhook(webhookUrl, payload);
|
||||
// Incoming webhooks don't return a message ID
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Notifier>;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,308 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { OrchestratorEvent, NotifyAction } from "@agent-orchestrator/core";
|
||||
import { manifest, create } from "./index.js";
|
||||
|
||||
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
type: "ci.failing",
|
||||
priority: "action",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-project",
|
||||
timestamp: new Date("2025-06-15T12:00:00Z"),
|
||||
message: "CI check failed on app-1",
|
||||
data: { checkName: "lint" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("notifier-webhook", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("webhook");
|
||||
expect(manifest.slot).toBe("notifier");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a notifier with name 'webhook'", () => {
|
||||
const notifier = create({ url: "https://example.com/hook" });
|
||||
expect(notifier.name).toBe("webhook");
|
||||
});
|
||||
|
||||
it("warns when no url configured", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
create();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No url configured"));
|
||||
});
|
||||
|
||||
it("throws on invalid URL scheme", () => {
|
||||
expect(() => create({ url: "ftp://example.com" })).toThrow("must be http(s)");
|
||||
});
|
||||
|
||||
it("throws on javascript: URL", () => {
|
||||
expect(() => create({ url: "javascript:alert(1)" })).toThrow("must be http(s)");
|
||||
});
|
||||
|
||||
it("accepts http:// URL", () => {
|
||||
expect(() => create({ url: "http://localhost:8080/hook" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts https:// URL", () => {
|
||||
expect(() => create({ url: "https://example.com/hook" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify", () => {
|
||||
it("does nothing when no url", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POSTs event as JSON to the configured URL", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ url: "https://example.com/hook" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("https://example.com/hook");
|
||||
|
||||
const opts = fetchMock.mock.calls[0][1];
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.type).toBe("notification");
|
||||
expect(body.event.id).toBe("evt-1");
|
||||
expect(body.event.sessionId).toBe("app-1");
|
||||
expect(body.event.type).toBe("ci.failing");
|
||||
});
|
||||
|
||||
it("serializes event timestamp as ISO string", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ url: "https://example.com/hook" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.event.timestamp).toBe("2025-06-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("includes custom headers", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
headers: { Authorization: "Bearer tok123" },
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1].headers;
|
||||
expect(headers["Authorization"]).toBe("Bearer tok123");
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyWithActions", () => {
|
||||
it("includes actions in payload", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const actions: NotifyAction[] = [
|
||||
{ label: "Merge", url: "https://github.com/pull/1/merge" },
|
||||
{ label: "Kill", callbackEndpoint: "/api/kill/app-1" },
|
||||
];
|
||||
|
||||
const notifier = create({ url: "https://example.com/hook" });
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.type).toBe("notification_with_actions");
|
||||
expect(body.actions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("post", () => {
|
||||
it("sends a plain message with context", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ url: "https://example.com/hook" });
|
||||
const result = await notifier.post!("All sessions complete", {
|
||||
projectId: "my-project",
|
||||
});
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.type).toBe("message");
|
||||
expect(body.message).toBe("All sessions complete");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("retry logic", () => {
|
||||
it("retries on 5xx and succeeds", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: () => Promise.resolve("unavailable"),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries on 429 Too Many Requests", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve("rate limited"),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does NOT retry on 400 Bad Request", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 400, text: () => Promise.resolve("bad request") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (400)");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT retry on 401 Unauthorized", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (401)");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT retry on 403 Forbidden", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve("forbidden") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (403)");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT retry on 404 Not Found", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 404, text: () => Promise.resolve("not found") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (404)");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws after all retries exhausted on 5xx", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("error") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow(
|
||||
"Webhook POST failed (500): error",
|
||||
);
|
||||
// 1 initial + 2 retries = 3
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries on network errors", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 1,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("respects retries=0 (no retries)", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("fail") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
url: "https://example.com/hook",
|
||||
retries: 0,
|
||||
retryDelayMs: 1,
|
||||
});
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1,175 @@
|
|||
// notifier-webhook plugin — to be implemented
|
||||
import {
|
||||
validateUrl,
|
||||
type PluginModule,
|
||||
type Notifier,
|
||||
type OrchestratorEvent,
|
||||
type NotifyAction,
|
||||
type NotifyContext,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
export const manifest = {
|
||||
name: "webhook",
|
||||
slot: "notifier" as const,
|
||||
description: "Notifier plugin: generic HTTP webhook",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
interface WebhookPayload {
|
||||
type: "notification" | "notification_with_actions" | "message";
|
||||
event?: {
|
||||
id: string;
|
||||
type: string;
|
||||
priority: string;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
url?: string;
|
||||
callbackEndpoint?: string;
|
||||
}>;
|
||||
message?: string;
|
||||
context?: NotifyContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the HTTP status code should be retried.
|
||||
* Only 429 (Too Many Requests) and 5xx (server errors) are retryable.
|
||||
* 4xx client errors (400, 401, 403, 404, etc.) are permanent failures.
|
||||
*/
|
||||
function isRetryableStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
async function postWithRetry(
|
||||
url: string,
|
||||
payload: WebhookPayload,
|
||||
headers: Record<string, string>,
|
||||
retries: number,
|
||||
retryDelayMs: number,
|
||||
): Promise<void> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.ok) return;
|
||||
|
||||
const body = await response.text();
|
||||
lastError = new Error(`Webhook POST failed (${response.status}): ${body}`);
|
||||
|
||||
// Only retry on 429 or 5xx — 4xx client errors are permanent
|
||||
if (!isRetryableStatus(response.status)) {
|
||||
throw lastError;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err === lastError) throw err; // Re-throw non-retryable errors from above
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
if (attempt < retries) {
|
||||
// Exponential backoff: delay * 2^attempt
|
||||
const delay = retryDelayMs * 2 ** attempt;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function serializeEvent(event: OrchestratorEvent): WebhookPayload["event"] {
|
||||
return {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
priority: event.priority,
|
||||
sessionId: event.sessionId,
|
||||
projectId: event.projectId,
|
||||
timestamp: event.timestamp.toISOString(),
|
||||
message: event.message,
|
||||
data: event.data,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function create(config?: Record<string, unknown>): Notifier {
|
||||
const url = config?.url as string | undefined;
|
||||
const rawHeaders = config?.headers;
|
||||
const customHeaders: Record<string, string> = {};
|
||||
if (rawHeaders && typeof rawHeaders === "object" && !Array.isArray(rawHeaders)) {
|
||||
for (const [k, v] of Object.entries(rawHeaders)) {
|
||||
if (typeof v === "string") customHeaders[k] = v;
|
||||
}
|
||||
}
|
||||
const rawRetries = (config?.retries as number) ?? 2;
|
||||
const rawDelay = (config?.retryDelayMs as number) ?? 1000;
|
||||
const retries = Number.isFinite(rawRetries) ? Math.max(0, rawRetries) : 2;
|
||||
const retryDelayMs = Number.isFinite(rawDelay) && rawDelay >= 0 ? rawDelay : 1000;
|
||||
|
||||
if (!url) {
|
||||
console.warn("[notifier-webhook] No url configured — notifications will be no-ops");
|
||||
} else {
|
||||
validateUrl(url, "notifier-webhook");
|
||||
}
|
||||
|
||||
return {
|
||||
name: "webhook",
|
||||
|
||||
async notify(event: OrchestratorEvent): Promise<void> {
|
||||
if (!url) return;
|
||||
|
||||
await postWithRetry(
|
||||
url,
|
||||
{ type: "notification", event: serializeEvent(event) },
|
||||
customHeaders,
|
||||
retries,
|
||||
retryDelayMs,
|
||||
);
|
||||
},
|
||||
|
||||
async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void> {
|
||||
if (!url) return;
|
||||
|
||||
await postWithRetry(
|
||||
url,
|
||||
{
|
||||
type: "notification_with_actions",
|
||||
event: serializeEvent(event),
|
||||
actions: actions.map((a) => ({
|
||||
label: a.label,
|
||||
url: a.url,
|
||||
callbackEndpoint: a.callbackEndpoint,
|
||||
})),
|
||||
},
|
||||
customHeaders,
|
||||
retries,
|
||||
retryDelayMs,
|
||||
);
|
||||
},
|
||||
|
||||
async post(message: string, context?: NotifyContext): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
|
||||
await postWithRetry(
|
||||
url,
|
||||
{ type: "message", message, context },
|
||||
customHeaders,
|
||||
retries,
|
||||
retryDelayMs,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Notifier>;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||
import type { Session } from "@agent-orchestrator/core";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
platform: vi.fn(() => "darwin"),
|
||||
}));
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { manifest, create, escapeAppleScript } from "./index.js";
|
||||
|
||||
const mockExecFile = execFile as unknown as Mock;
|
||||
const mockPlatform = platform as unknown as Mock;
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "my-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/workspace",
|
||||
runtimeHandle: null,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function simulateOsascript(stdout: string) {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
|
||||
cb(null, stdout);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function simulateOsascriptSequence(results: string[]) {
|
||||
let callIndex = 0;
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
|
||||
const result = results[callIndex] ?? "NOT_FOUND";
|
||||
callIndex++;
|
||||
cb(null, result);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("terminal-iterm2", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockPlatform.mockReturnValue("darwin");
|
||||
});
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("iterm2");
|
||||
expect(manifest.slot).toBe("terminal");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeAppleScript", () => {
|
||||
it("escapes double quotes", () => {
|
||||
expect(escapeAppleScript('hello "world"')).toBe('hello \\"world\\"');
|
||||
});
|
||||
|
||||
it("escapes backslashes", () => {
|
||||
expect(escapeAppleScript("path\\to\\file")).toBe("path\\\\to\\\\file");
|
||||
});
|
||||
|
||||
it("escapes both backslashes and quotes together", () => {
|
||||
expect(escapeAppleScript('a\\b"c')).toBe('a\\\\b\\"c');
|
||||
});
|
||||
|
||||
it("returns plain strings unchanged", () => {
|
||||
expect(escapeAppleScript("hello-world_123")).toBe("hello-world_123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a terminal with name 'iterm2'", () => {
|
||||
const terminal = create();
|
||||
expect(terminal.name).toBe("iterm2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openSession", () => {
|
||||
it("uses session.id as session name by default", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession({ id: "backend-5" }));
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
||||
const newTabScript = mockExecFile.mock.calls[1][1][1] as string;
|
||||
expect(newTabScript).toContain("backend-5");
|
||||
});
|
||||
|
||||
it("uses runtimeHandle.id when available", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.openSession(
|
||||
makeSession({
|
||||
id: "app-1",
|
||||
runtimeHandle: { id: "tmux-session-42", runtimeName: "tmux", data: {} },
|
||||
}),
|
||||
);
|
||||
|
||||
const newTabScript = mockExecFile.mock.calls[1][1][1] as string;
|
||||
expect(newTabScript).toContain("tmux-session-42");
|
||||
expect(newTabScript).not.toContain("app-1");
|
||||
});
|
||||
|
||||
it("reuses existing tab when found", async () => {
|
||||
simulateOsascript("FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession());
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens new tab when not found", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession());
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("escapes special chars in session name for AppleScript", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession({ id: 'session"with"quotes' }));
|
||||
|
||||
const findScript = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(findScript).toContain('session\\"with\\"quotes');
|
||||
expect(findScript).not.toContain('session"with"quotes');
|
||||
|
||||
const openScript = mockExecFile.mock.calls[1][1][1] as string;
|
||||
expect(openScript).toContain('session\\"with\\"quotes');
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppleScript commands", () => {
|
||||
it("findAndSelectExistingTab checks session name and selects", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession({ id: "my-session" }));
|
||||
|
||||
const findScript = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(findScript).toContain('tell application "iTerm2"');
|
||||
expect(findScript).toContain("name of aSession");
|
||||
expect(findScript).not.toContain("profile name of aSession");
|
||||
expect(findScript).toContain('"my-session"');
|
||||
expect(findScript).toContain("select aWindow");
|
||||
expect(findScript).toContain("select aTab");
|
||||
});
|
||||
|
||||
it("openNewTab creates tab and attaches to tmux with shell-safe quoting", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession({ id: "app-7" }));
|
||||
|
||||
const openScript = mockExecFile.mock.calls[1][1][1] as string;
|
||||
expect(openScript).toContain("create tab with default profile");
|
||||
expect(openScript).toContain('set name to "app-7"');
|
||||
expect(openScript).toContain("tmux attach -t 'app-7'");
|
||||
});
|
||||
|
||||
it("shell-escapes session names with single quotes in tmux command", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession({ id: "it's-a-test" }));
|
||||
|
||||
const openScript = mockExecFile.mock.calls[1][1][1] as string;
|
||||
// Single quotes in the session name should be shell-escaped then AppleScript-escaped.
|
||||
// Shell escape: 'it'\''s-a-test' → AppleScript escape doubles the backslash: 'it'\\''s-a-test'
|
||||
expect(openScript).toContain("tmux attach -t 'it'\\\\''s-a-test'");
|
||||
});
|
||||
|
||||
it("always calls osascript as the command", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession());
|
||||
|
||||
for (const call of mockExecFile.mock.calls) {
|
||||
expect(call[0]).toBe("osascript");
|
||||
expect(call[1][0]).toBe("-e");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("openAll", () => {
|
||||
it("does nothing for empty session list", async () => {
|
||||
const terminal = create();
|
||||
await terminal.openAll([]);
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens tabs for each session", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
const sessions = [makeSession({ id: "app-1" }), makeSession({ id: "app-2" })];
|
||||
|
||||
const promise = terminal.openAll(sessions);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await promise;
|
||||
|
||||
// 2 sessions * 2 calls each (find + open) = 4
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("skips opening tabs for existing sessions", async () => {
|
||||
simulateOsascriptSequence(["FOUND\n", "NOT_FOUND\n", ""]);
|
||||
const terminal = create();
|
||||
const sessions = [makeSession({ id: "existing-1" }), makeSession({ id: "new-1" })];
|
||||
|
||||
const promise = terminal.openAll(sessions);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await promise;
|
||||
|
||||
// existing-1: 1 call (find=FOUND), new-1: 2 calls (find=NOT_FOUND + open)
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform guard", () => {
|
||||
it("openSession is a no-op on non-macOS", async () => {
|
||||
mockPlatform.mockReturnValue("linux");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession());
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("only available on macOS"));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("isSessionOpen returns false on non-macOS", async () => {
|
||||
mockPlatform.mockReturnValue("win32");
|
||||
const terminal = create();
|
||||
const result = await terminal.isSessionOpen!(makeSession());
|
||||
expect(result).toBe(false);
|
||||
expect(mockExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSessionOpen", () => {
|
||||
it("returns true when tab exists", async () => {
|
||||
simulateOsascript("FOUND\n");
|
||||
const terminal = create();
|
||||
const result = await terminal.isSessionOpen!(makeSession({ id: "app-1" }));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when tab does not exist", async () => {
|
||||
simulateOsascript("NOT_FOUND\n");
|
||||
const terminal = create();
|
||||
const result = await terminal.isSessionOpen!(makeSession({ id: "app-1" }));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when osascript fails", async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
|
||||
cb(new Error("osascript failed"), "");
|
||||
},
|
||||
);
|
||||
const terminal = create();
|
||||
const result = await terminal.isSessionOpen!(makeSession());
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT select the tab (no side effect)", async () => {
|
||||
simulateOsascript("FOUND\n");
|
||||
const terminal = create();
|
||||
await terminal.isSessionOpen!(makeSession({ id: "check-only" }));
|
||||
|
||||
// isSessionOpen uses hasExistingTab which does NOT contain select commands
|
||||
const script = mockExecFile.mock.calls[0][1][1] as string;
|
||||
expect(script).not.toContain("select aWindow");
|
||||
expect(script).not.toContain("select aTab");
|
||||
// Uses session name, not profile name
|
||||
expect(script).toContain("name of aSession");
|
||||
expect(script).not.toContain("profile name");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1,178 @@
|
|||
// terminal-iterm2 plugin — to be implemented
|
||||
import { execFile } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import {
|
||||
escapeAppleScript,
|
||||
type PluginModule,
|
||||
type Terminal,
|
||||
type Session,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
export const manifest = {
|
||||
name: "iterm2",
|
||||
slot: "terminal" as const,
|
||||
description: "Terminal plugin: macOS iTerm2 tab management",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { escapeAppleScript } from "@agent-orchestrator/core";
|
||||
|
||||
/**
|
||||
* Run an AppleScript snippet and return stdout.
|
||||
*/
|
||||
function runAppleScript(script: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("osascript", ["-e", script], (err, stdout) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe interpolation inside a shell single-quoted context.
|
||||
* Replaces ' with '\'' (end quote, escaped quote, start quote).
|
||||
*/
|
||||
function shellEscape(s: string): string {
|
||||
return s.replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an iTerm2 tab already exists for this session by matching session name.
|
||||
* Returns true if found (and selects it), false otherwise.
|
||||
*/
|
||||
async function findAndSelectExistingTab(sessionName: string): Promise<boolean> {
|
||||
const safe = escapeAppleScript(sessionName);
|
||||
const script = `
|
||||
tell application "iTerm2"
|
||||
repeat with aWindow in windows
|
||||
repeat with aTab in tabs of aWindow
|
||||
repeat with aSession in sessions of aTab
|
||||
try
|
||||
if name of aSession is equal to "${safe}" then
|
||||
select aWindow
|
||||
select aTab
|
||||
return "FOUND"
|
||||
end if
|
||||
end try
|
||||
end repeat
|
||||
end repeat
|
||||
end repeat
|
||||
return "NOT_FOUND"
|
||||
end tell`;
|
||||
|
||||
const result = await runAppleScript(script);
|
||||
return result === "FOUND";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an iTerm2 tab exists for this session WITHOUT selecting it.
|
||||
* Pure query — no side effects on the UI.
|
||||
*/
|
||||
async function hasExistingTab(sessionName: string): Promise<boolean> {
|
||||
const safe = escapeAppleScript(sessionName);
|
||||
const script = `
|
||||
tell application "iTerm2"
|
||||
repeat with aWindow in windows
|
||||
repeat with aTab in tabs of aWindow
|
||||
repeat with aSession in sessions of aTab
|
||||
try
|
||||
if name of aSession is equal to "${safe}" then
|
||||
return "FOUND"
|
||||
end if
|
||||
end try
|
||||
end repeat
|
||||
end repeat
|
||||
end repeat
|
||||
return "NOT_FOUND"
|
||||
end tell`;
|
||||
|
||||
const result = await runAppleScript(script);
|
||||
return result === "FOUND";
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new iTerm2 tab and attach to the given tmux session.
|
||||
* Creates a new window if no window is open.
|
||||
*/
|
||||
async function openNewTab(sessionName: string): Promise<void> {
|
||||
const safe = escapeAppleScript(sessionName);
|
||||
// Double-escape for the write text command: shell-escape first, then AppleScript-escape
|
||||
// the whole shell command so it's safe inside the AppleScript double-quoted string.
|
||||
const shellSafe = shellEscape(sessionName);
|
||||
const shellInAppleScript = escapeAppleScript(shellSafe);
|
||||
const script = `
|
||||
tell application "iTerm2"
|
||||
activate
|
||||
if (count of windows) is 0 then
|
||||
create window with default profile
|
||||
else
|
||||
tell current window
|
||||
create tab with default profile
|
||||
end tell
|
||||
end if
|
||||
tell current session of current window
|
||||
set name to "${safe}"
|
||||
write text "printf '\\\\033]0;${shellInAppleScript}\\\\007' && tmux attach -t '${shellInAppleScript}'"
|
||||
end tell
|
||||
end tell`;
|
||||
|
||||
await runAppleScript(script);
|
||||
}
|
||||
|
||||
function getSessionName(session: Session): string {
|
||||
// Use the runtime handle id if available (tmux session name), otherwise session id
|
||||
return session.runtimeHandle?.id ?? session.id;
|
||||
}
|
||||
|
||||
function isMacOS(): boolean {
|
||||
return platform() === "darwin";
|
||||
}
|
||||
|
||||
export function create(): Terminal {
|
||||
return {
|
||||
name: "iterm2",
|
||||
|
||||
async openSession(session: Session): Promise<void> {
|
||||
if (!isMacOS()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[terminal-iterm2] iTerm2 is only available on macOS");
|
||||
return;
|
||||
}
|
||||
const sessionName = getSessionName(session);
|
||||
|
||||
// Try to find and select an existing tab first
|
||||
const found = await findAndSelectExistingTab(sessionName);
|
||||
if (!found) {
|
||||
await openNewTab(sessionName);
|
||||
}
|
||||
},
|
||||
|
||||
async openAll(sessions: Session[]): Promise<void> {
|
||||
if (!isMacOS() || sessions.length === 0) return;
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionName = getSessionName(session);
|
||||
const found = await findAndSelectExistingTab(sessionName);
|
||||
if (!found) {
|
||||
await openNewTab(sessionName);
|
||||
}
|
||||
// Small delay between tab operations to avoid AppleScript race conditions
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
},
|
||||
|
||||
async isSessionOpen(session: Session): Promise<boolean> {
|
||||
if (!isMacOS()) return false;
|
||||
const sessionName = getSessionName(session);
|
||||
try {
|
||||
// Query-only check — does NOT select/focus the tab
|
||||
return await hasExistingTab(sessionName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Terminal>;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session } from "@agent-orchestrator/core";
|
||||
import { manifest, create } from "./index.js";
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "my-project",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/workspace",
|
||||
runtimeHandle: null,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("terminal-web", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("web");
|
||||
expect(manifest.slot).toBe("terminal");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a terminal with name 'web'", () => {
|
||||
const terminal = create();
|
||||
expect(terminal.name).toBe("web");
|
||||
});
|
||||
|
||||
it("has openSession, openAll, and isSessionOpen methods", () => {
|
||||
const terminal = create();
|
||||
expect(typeof terminal.openSession).toBe("function");
|
||||
expect(typeof terminal.openAll).toBe("function");
|
||||
expect(typeof terminal.isSessionOpen).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openSession", () => {
|
||||
it("marks session as open", async () => {
|
||||
const terminal = create();
|
||||
const session = makeSession({ id: "app-1" });
|
||||
|
||||
expect(await terminal.isSessionOpen!(session)).toBe(false);
|
||||
await terminal.openSession(session);
|
||||
expect(await terminal.isSessionOpen!(session)).toBe(true);
|
||||
});
|
||||
|
||||
it("logs the terminal URL", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession({ id: "backend-3" }));
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("backend-3"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("/sessions/backend-3/terminal"));
|
||||
});
|
||||
|
||||
it("uses default dashboard URL", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const terminal = create();
|
||||
await terminal.openSession(makeSession());
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("http://localhost:3000"));
|
||||
});
|
||||
|
||||
it("uses custom dashboard URL", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const terminal = create({ dashboardUrl: "https://my-dashboard.io" });
|
||||
await terminal.openSession(makeSession());
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("https://my-dashboard.io"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("openAll", () => {
|
||||
it("marks all sessions as open", async () => {
|
||||
const terminal = create();
|
||||
const sessions = [
|
||||
makeSession({ id: "app-1" }),
|
||||
makeSession({ id: "app-2" }),
|
||||
makeSession({ id: "app-3" }),
|
||||
];
|
||||
|
||||
await terminal.openAll(sessions);
|
||||
|
||||
for (const session of sessions) {
|
||||
expect(await terminal.isSessionOpen!(session)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles empty sessions list", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const terminal = create();
|
||||
await terminal.openAll([]);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("0 sessions"));
|
||||
});
|
||||
|
||||
it("logs the count of sessions", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const terminal = create();
|
||||
await terminal.openAll([makeSession({ id: "a" }), makeSession({ id: "b" })]);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("2 sessions"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSessionOpen", () => {
|
||||
it("returns false for sessions never opened", async () => {
|
||||
const terminal = create();
|
||||
expect(await terminal.isSessionOpen!(makeSession({ id: "never-opened" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true only for sessions that were opened", async () => {
|
||||
const terminal = create();
|
||||
const s1 = makeSession({ id: "opened-1" });
|
||||
const s2 = makeSession({ id: "not-opened" });
|
||||
|
||||
await terminal.openSession(s1);
|
||||
|
||||
expect(await terminal.isSessionOpen!(s1)).toBe(true);
|
||||
expect(await terminal.isSessionOpen!(s2)).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks sessions independently across openSession and openAll", async () => {
|
||||
const terminal = create();
|
||||
const s1 = makeSession({ id: "single" });
|
||||
const s2 = makeSession({ id: "batch-a" });
|
||||
const s3 = makeSession({ id: "batch-b" });
|
||||
const s4 = makeSession({ id: "never" });
|
||||
|
||||
await terminal.openSession(s1);
|
||||
await terminal.openAll([s2, s3]);
|
||||
|
||||
expect(await terminal.isSessionOpen!(s1)).toBe(true);
|
||||
expect(await terminal.isSessionOpen!(s2)).toBe(true);
|
||||
expect(await terminal.isSessionOpen!(s3)).toBe(true);
|
||||
expect(await terminal.isSessionOpen!(s4)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1,52 @@
|
|||
// terminal-web plugin — to be implemented
|
||||
import type { PluginModule, Terminal, Session } from "@agent-orchestrator/core";
|
||||
|
||||
export const manifest = {
|
||||
name: "web",
|
||||
slot: "terminal" as const,
|
||||
description: "Terminal plugin: xterm.js web terminal",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
/**
|
||||
* Web terminal plugin.
|
||||
*
|
||||
* Unlike iTerm2, this doesn't directly open anything — the web dashboard
|
||||
* uses xterm.js to connect to sessions via the runtime's attach info.
|
||||
* This plugin provides the URL generation and session-open tracking
|
||||
* that the dashboard needs.
|
||||
*/
|
||||
export function create(config?: Record<string, unknown>): Terminal {
|
||||
const dashboardUrl = (config?.dashboardUrl as string) ?? "http://localhost:3000";
|
||||
|
||||
// Track which sessions have been "opened" (URL generated for the dashboard)
|
||||
const openSessions = new Set<string>();
|
||||
|
||||
return {
|
||||
name: "web",
|
||||
|
||||
async openSession(session: Session): Promise<void> {
|
||||
openSessions.add(session.id);
|
||||
// In a web context, "opening" means the dashboard should show the terminal.
|
||||
// The actual xterm.js connection is handled by the web dashboard frontend
|
||||
// using the session's runtime attach info.
|
||||
console.log(
|
||||
`[terminal-web] Session ${session.id} terminal available at ${dashboardUrl}/sessions/${session.id}/terminal`,
|
||||
);
|
||||
},
|
||||
|
||||
async openAll(sessions: Session[]): Promise<void> {
|
||||
for (const session of sessions) {
|
||||
openSessions.add(session.id);
|
||||
}
|
||||
console.log(
|
||||
`[terminal-web] ${sessions.length} sessions available at ${dashboardUrl}/sessions`,
|
||||
);
|
||||
},
|
||||
|
||||
async isSessionOpen(session: Session): Promise<boolean> {
|
||||
return openSessions.has(session.id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Terminal>;
|
||||
|
|
|
|||
976
pnpm-lock.yaml
976
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue