import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterAll, beforeAll, expect, it, vi } from "vitest"; import { launchVerificationServer, runControlOmb } from "../scripts/control-omb.ts"; import { startBrokerFake, type BrokerFake } from "./testing/broker-fake.ts"; // App events end to end on a real server (temporary home, scripted engine) against the connected-apps service's own // code with a fake Composio behind it: a chat request becomes an app-event automation, a Composio delivery reaches the // service's queue, the server's long-poll turns it into a run, and the automation shows what happened. let broker: BrokerFake; let owner: { id: string; token: string; userId: string }; let gmail = ""; let fixture: Awaited>; const evidence: unknown[] = []; beforeAll(async () => { vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); broker = await startBrokerFake(); owner = await broker.install(); gmail = broker.account(owner.userId, "gmail", "sales"); fixture = await launchVerificationServer({ ...process.env, OMB_COMPOSIO_BROKER_URL: broker.url, OMB_COMPOSIO_BROKER_TOKEN: owner.token }, undefined, undefined, undefined, undefined, { scripted: true }); evidence.push({ fixture: fixture.info, broker: broker.url }); }, 60_000); afterAll(async () => { writeFileSync(`${fixture.info.logPath}.app-events.json`, JSON.stringify(evidence, null, 2)); console.info(JSON.stringify({ logPath: fixture.info.logPath, evidence: `${fixture.info.logPath}.app-events.json` })); await fixture?.close(); await broker?.close(); vi.restoreAllMocks(); }); it("sets up an app-event automation from chat, runs on a Composio event, and shows pause, skips, reconnect and undo", async () => { const planPath = join(fixture.info.dataDir, "room-plan.json"); const turns = () => existsSync(`${planPath}.evidence.jsonl`) ? readFileSync(`${planPath}.evidence.jsonl`, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)) : []; const api = async (method: string, path: string, body?: unknown, status = 200) => { const response = await fetch(`${fixture.info.url}${path}`, { method, headers: { "content-type": "application/json", origin: fixture.info.url }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const value = await response.json() as any; expect(response.status, `${method} ${path}: ${JSON.stringify(value)}`).toBe(status); return value; }; const cli = (...args: string[]) => runControlOmb([...args, "--url", fixture.info.url]) as Promise; const { bot } = await cli("new-bot", "--name", "Scout"); await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "ask" }); const threadId = bot.activeTaskId as string; const messages = async () => (await api("GET", `/api/threads/${threadId}/messages?limit=200`)).messages as any[]; const turn = async (text: string, steps: unknown[], reply = "Done.") => { const before = turns().length; writeFileSync(planPath, JSON.stringify({ [bot.id]: { steps, reply } })); await cli("send", "--bot", bot.id, "--task", threadId, "--text", text); await expect.poll(() => turns().length, { timeout: 20_000 }).toBe(before + 1); await cli("wait", "--bot", bot.id, "--task", threadId, "--timeout", "20"); const latest = turns().at(-1); evidence.push({ turn: latest }); return latest.evidence as Array<{ step?: { tool: string }; response: any }>; }; const toolText = (entries: Array<{ step?: { tool: string }; response: any }>, tool: string) => entries.find((entry) => entry.step?.tool === tool)!.response.result.content[0].text as string; const snapshot = async () => api("GET", "/api/app-triggers"); const trigger = async (id: string) => (await snapshot()).triggers.find((candidate: any) => candidate.id === id); const binding = (triggerId: string) => broker.harness.d1.rows<{ disabled_at: number | null }>("SELECT disabled_at FROM trigger_bindings WHERE composio_trigger_id = ?", triggerId)[0]; // 1. "When I get an email from Acme, draft a reply": the bot looks up Gmail's events and sets it up in the same turn. const created = await turn("When I get an email from Acme, draft a reply.", [ { tool: "list_trigger_events", arguments: { app: "gmail" } }, { tool: "create_trigger", arguments: { when: "app_event", name: "Acme replies", app: "gmail", event: "GMAIL_NEW_GMAIL_MESSAGE", filter: { from: "acme.com" }, instructions: "Draft a reply to the email in the event data. Do not send it." } }, ]); const listed = toolText(created, "list_trigger_events"); expect(listed).toContain("GMAIL_NEW_GMAIL_MESSAGE"); expect(listed).toContain('"delivery": "poll"'); expect(toolText(created, "create_trigger")).toContain("applied at once"); const card = (await messages()).findLast((message) => message.card?.appTriggerRequest)!.card; expect(card).toMatchObject({ autoApplied: true, answered: "allow", tool: "create_trigger" }); expect(card.subtitle).toContain("When an email arrives in Gmail (sales) from acme.com (it checks every 15 minutes), Scout follows"); expect((await messages()).filter((message) => message.card && !message.card.answered && !message.card.dismissed)).toEqual([]); const id = card.appTriggerRequest.resultId as string; const made = await trigger(id); expect(made).toMatchObject({ botId: bot.id, app: "gmail", accountId: gmail, delivery: "poll", enabled: true, remote: { state: "active" }, received: 0 }); const remoteId = made.remote.triggerId as string; expect(broker.harness.composio.instances.get(remoteId)?.trigger_config).toEqual({ query: "from:acme.com" }); // 2. A Composio delivery reaches the service's queue; the server's long-poll starts a run that completes. writeFileSync(planPath, JSON.stringify({ [bot.id]: { steps: [], reply: "Drafted a reply to Ada about order #1042." } })); expect((await broker.deliver(remoteId, { sender: "Ada ", subject: "Where is order #1042?", message_text: "Hi" }, "msg_e2e_1")).status).toBe(200); await expect.poll(async () => (await trigger(id))?.received, { timeout: 30_000 }).toBe(1); const runId = (await trigger(id)).lastRunId as string; await expect.poll(async () => (await api("GET", "/api/routines")).runs.find((run: any) => run.id === runId)?.status, { timeout: 30_000 }).toBe("completed"); const run = (await api("GET", "/api/routines")).runs.find((candidate: any) => candidate.id === runId); expect(run).toMatchObject({ routineId: id, triggerSource: "webhook", eventSource: "app", deliveryId: "msg_e2e_1" }); expect(run.output).toContain("Drafted a reply"); expect((await snapshot()).attempts.find((attempt: any) => attempt.eventId === "msg_e2e_1")).toMatchObject({ outcome: "started", runId, preview: "From Ada ยท Where is order #1042?" }); await expect.poll(() => broker.harness.d1.rows("SELECT * FROM events WHERE acked_at IS NULL").length, { timeout: 30_000 }).toBe(0); expect((await messages()).some((message) => message.kind === "routine.run")).toBe(false); // 3. Pause and resume from chat reach the service; nothing waits on a card. const paused = await turn("Pause the Acme replies.", [{ tool: "manage_trigger", arguments: { trigger_id: id, action: "pause" } }]); expect(toolText(paused, "manage_trigger")).toContain("applied at once"); expect((await trigger(id)).enabled).toBe(false); expect(binding(remoteId)?.disabled_at).not.toBeNull(); await turn("Turn the Acme replies back on.", [{ tool: "manage_trigger", arguments: { trigger_id: id, action: "resume" } }]); expect((await trigger(id)).enabled).toBe(true); expect(binding(remoteId)?.disabled_at).toBeNull(); // 4. Past the desktop's 500 events a day the service keeps nothing more, and the automation says how many it skipped. broker.setQueuedToday(owner.id, 500); await broker.deliver(remoteId, { sender: "bo@acme.com", subject: "Over the limit" }); const synced = await api("POST", "/api/app-triggers/sync"); expect(synced.service).toMatchObject({ available: true, plan: "desktop", eventsPerDay: 500 }); expect(synced.triggers.find((candidate: any) => candidate.id === id).skipped).toMatchObject({ dailyLimit: 1 }); // 5. An expired Gmail connection shows as Reconnect. await broker.expire(gmail); await expect.poll(async () => (await trigger(id))?.problem?.kind, { timeout: 30_000 }).toBe("reconnect"); const listedAgain = await turn("How are my automations?", [{ tool: "list_triggers", arguments: {} }]); expect(toolText(listedAgain, "list_triggers")).toContain("needs to be reconnected"); // 6. Undo on the card deletes it here and at the service. await api("POST", `/api/threads/${threadId}/undo`, { requestId: card.requestId }); expect(await trigger(id)).toBeUndefined(); expect(broker.harness.composio.instances.has(remoteId)).toBe(false); expect((await messages()).find((message) => message.card?.requestId === card.requestId)!.card.undone).toBe(true); }, 180_000);