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:
prateek 2026-02-14 16:29:59 +05:30 committed by GitHub
parent 06b5ec3267
commit 90f14a6ca5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 5162 additions and 30 deletions

View File

@ -15,7 +15,7 @@ jobs:
integration:
name: Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

View File

@ -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({}),
});

View File

@ -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";

View File

@ -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" },

View File

@ -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}"`);
}
}

View File

@ -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"

View File

@ -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;
// ---------------------------------------------------------------------------

View File

@ -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

View File

@ -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

View File

@ -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;
// ---------------------------------------------------------------------------

View File

@ -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,
}));
}

View File

@ -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");
});
});
});

View File

@ -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");
});
});
});

View File

@ -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}^`);
});
});
});

View File

@ -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();
});
});
});

View File

@ -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);
});
});
});

View File

@ -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"));
});
});
});

View File

@ -5,11 +5,6 @@ export default defineConfig({
testTimeout: 120_000,
hookTimeout: 60_000,
pool: "forks",
poolOptions: {
forks: {
singleFork: true,
},
},
include: ["src/**/*.integration.test.ts"],
},
});

View File

@ -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"
}
}

View File

@ -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();
});
});
});

View File

@ -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>;

View File

@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -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"
}
}

View File

@ -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");
});
});
});

View File

@ -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>;

View File

@ -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"
}
}

View File

@ -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();
});
});
});

View File

@ -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>;

View File

@ -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"
}
}

View File

@ -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);
});
});
});

View File

@ -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>;

View File

@ -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"
}
}

View File

@ -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");
});
});
});

View File

@ -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>;

View File

@ -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"
}
}

View File

@ -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);
});
});
});

View File

@ -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>;

File diff suppressed because it is too large Load Diff