Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175import crypto from "node:crypto";
import { db, eq } from "@openstatus/db";import { incident, incidentEvent, integration,} from "@openstatus/db/src/schema";import { createTestWorkspace } from "@openstatus/db/src/test/factories";import { afterEach, beforeEach, describe, expect, test,} from "@openstatus/test-utils";import { Hono } from "hono";
// workspace-resolver / @slack/web-api / agent are swapped for doubles via the// test import map; behavior is driven through this shared mutable state.import { slackTestState } from "@/libs/test/doubles/slack-test-state";import { TEST_SIGNING_SECRET as SIGNING_SECRET, withSlackConfig,} from "@/libs/test/slack-config";
import { settleBackgroundTasks } from "./background";import type { SlackEnv } from "./config";import { handleSlackEvent, isAnswerToAgent, looksLikeUncardedDraft, toolTaskTitle,} from "./handler";import { abortTurn, broadcastStop, endTurn, startTurn } from "./running-turns";import { verifySlackSignature } from "./verify";
function createTestApp() { const app = withSlackConfig(new Hono<SlackEnv>()); app.post("/slack/events", verifySlackSignature, handleSlackEvent); return app;}
function signAndPost( app: ReturnType<typeof createTestApp>, body: Record<string, unknown>,) { const rawBody = JSON.stringify(body); const timestamp = Math.floor(Date.now() / 1000); const basestring = `v0:${timestamp}:${rawBody}`; const sig = crypto .createHmac("sha256", SIGNING_SECRET) .update(basestring) .digest("hex");
return app.request("/slack/events", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": `v0=${sig}`, }, body: rawBody, });}
// Generous: the member gate hits the DB, which is slow under `--parallel`.async function waitForCall(method: string, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const call = slackTestState.calls.find((m) => m.method === method); if (call) return call; await new Promise((r) => setTimeout(r, 10)); } return undefined;}
function resetSlackTestState() { slackTestState.calls = []; // The seeded member of workspace 1, so every test user is linked. slackTestState.usersInfoImpl = () => Promise.resolve({ ok: true, user: { profile: { email: "ping@openstatus.dev" } }, }); slackTestState.postMessageOverride = null; slackTestState.updateOverride = null; slackTestState.postEphemeralOverride = null; slackTestState.runAgentOverride = null; slackTestState.renameOverride = null; slackTestState.chatStreamEnabled = true; slackTestState.historyImpl = () => Promise.resolve({ messages: [{ user: "U1", text: "channel message", ts: "1.1" }], }); slackTestState.streamAppendFailAfter = null; slackTestState.streamStopFail = false; slackTestState.repliesImpl = () => Promise.resolve({ messages: [{ user: "U1", text: "test message", ts: "1.1" }], }); // Default to a workspace without agent sessions so the tests below cover // the fallback indicators; the session path opts back in explicitly. slackTestState.sessionStatusOverride = () => { const err = new Error("An API error occurred: feature_disabled"); Object.assign(err, { code: "slack_webapi_platform_error", data: { ok: false, error: "feature_disabled" }, }); return Promise.reject(err); }; slackTestState.resolveWorkspace = (teamId: string) => { if (teamId === "T_KNOWN") { return Promise.resolve({ workspace: { id: 1, name: "Test Workspace", slug: "test", plan: "free", limits: { "slack-agent": true }, }, botToken: "xoxb-test", botUserId: "UBOT", }); } return Promise.resolve(null); };}
describe("handleSlackEvent", () => { const app = createTestApp();
beforeEach(resetSlackTestState);
test("responds to url_verification challenge", async () => { const res = await signAndPost(app, { type: "url_verification", challenge: "test-challenge-123", });
expect(res.status).toBe(200); const json = (await res.json()) as { challenge: string }; expect(json.challenge).toBe("test-challenge-123"); });
test("returns ok for non-event_callback types", async () => { const res = await signAndPost(app, { type: "app_rate_limited", });
expect(res.status).toBe(200); const json = (await res.json()) as { ok: boolean }; expect(json.ok).toBe(true); });
test("returns ok for event_callback", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_${Date.now()}_1`, event: { type: "app_mention", text: "<@UBOT> create an incident", user: "U1", channel: "C1", ts: "100.1", }, });
expect(res.status).toBe(200); const json = (await res.json()) as { ok: boolean }; expect(json.ok).toBe(true); });
test("publishes the home view on app_home_opened", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_home_${Date.now()}`, event: { type: "app_home_opened", tab: "home", user: "U1", }, });
expect(res.status).toBe(200); const publish = await waitForCall("views.publish"); expect(publish).toBeDefined(); expect((publish?.args.view as { type: string }).type).toBe("home"); expect(publish?.args.user_id).toBe("U1"); });
test("ignores app_home_opened for the messages tab", async () => { // Earlier tests' events may still be posting; only this one's calls count. await settleBackgroundTasks(); slackTestState.calls = []; const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_home_msgs_${Date.now()}`, event: { type: "app_home_opened", tab: "messages", user: "U1", }, });
expect(res.status).toBe(200); await settleBackgroundTasks(); expect(slackTestState.calls.length).toBe(0); });
test("responds once when a mention arrives as app_mention and message", async () => { const ts = `${Date.now()}.55`; const base = { type: "event_callback", team_id: "T_KNOWN", event: { text: "<@UBOT> create an incident", user: "U1", channel: "C1", channel_type: "channel", ts, }, };
// Same underlying message, delivered as two distinct events. await signAndPost(app, { ...base, event_id: `evt_mention_${ts}`, event: { ...base.event, type: "app_mention" }, }); await signAndPost(app, { ...base, event_id: `evt_message_${ts}`, event: { ...base.event, type: "message" }, }); await new Promise((r) => setTimeout(r, 100));
const thinking = slackTestState.calls.filter( (m) => m.method === "postMessage", ); expect(thinking.length).toBe(1); });
test("handles app_uninstalled event", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_uninstall_${Date.now()}`, event: { type: "app_uninstalled", }, });
expect(res.status).toBe(200); });
test("handles tokens_revoked event", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_revoked_${Date.now()}`, event: { type: "tokens_revoked", }, });
expect(res.status).toBe(200); });
test("deduplicates events with same event_id", async () => { const eventId = `evt_dedup_${Date.now()}`; const body = { type: "event_callback", team_id: "T_KNOWN", event_id: eventId, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.1`, }, };
await signAndPost(app, body); await new Promise((r) => setTimeout(r, 50));
slackTestState.calls = []; await signAndPost(app, body); await new Promise((r) => setTimeout(r, 50));
expect(slackTestState.calls.length).toBe(0); });
test("ignores events from unknown teams", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_UNKNOWN", event_id: `evt_unknown_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.2`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("ignores message events from bots", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_bot_${Date.now()}`, event: { type: "message", text: "bot message", bot_id: "B123", channel: "C1", ts: `${Date.now()}.3`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("ignores channel message without bot mention", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_nomention_${Date.now()}`, event: { type: "message", text: "just a regular message", user: "U1", channel: "C1", channel_type: "channel", ts: `${Date.now()}.4`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("marks the agent session as processing instead of posting Thinking", async () => { slackTestState.sessionStatusOverride = null; const ts = `${Date.now()}.60`;
await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_session_${ts}`, event: { type: "app_mention", text: "<@UBOT> which reports are open?", user: "U1", channel: "C1", ts, }, }); await new Promise((r) => setTimeout(r, 100));
const statuses = slackTestState.calls .filter((m) => m.method === "agents.sessions.setStatus") .map((m) => m.args); expect(statuses).toEqual([ { channel_id: "C1", thread_ts: ts, status: "processing", initiator_user_id: "U1", }, { channel_id: "C1", thread_ts: ts, status: "active" }, ]);
const posts = slackTestState.calls.filter( (m) => m.method === "postMessage", ); expect(posts.length).toBe(1); expect(posts[0].args).toMatchObject({ channel: "C1", thread_ts: ts, text: "Here is my response", }); expect(slackTestState.calls.some((m) => m.method === "update")).toBe(false); });
test("hands the agent session back as active when runAgent throws", async () => { slackTestState.sessionStatusOverride = null; slackTestState.runAgentOverride = () => Promise.reject(new Error("agent exploded")); const ts = `${Date.now()}.61`;
await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_session_err_${ts}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts, }, }); await new Promise((r) => setTimeout(r, 100));
const errorPost = slackTestState.calls.find( (m) => m.method === "postMessage" && typeof m.args.text === "string" && m.args.text.includes("Something went wrong"), ); expect(errorPost).toBeDefined(); const last = slackTestState.calls .filter((m) => m.method === "agents.sessions.setStatus") .at(-1); expect(last?.args.status).toBe("active"); });
test("answers an untagged reply to its own question", async () => { const ts = `${Date.now()}.70`; slackTestState.repliesImpl = () => Promise.resolve({ messages: [ { user: "U1", text: "<@UBOT> update my status page", ts: "5.1" }, { user: "UBOT", bot_id: "B1", text: "Which page?", ts: "5.2" }, { user: "U1", text: "acme, id 1", ts }, ], });
await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_untagged_${ts}`, event: { type: "message", text: "acme, id 1", user: "U1", channel: "C1", channel_type: "channel", ts, thread_ts: "5.1", }, }); await new Promise((r) => setTimeout(r, 100));
const answer = slackTestState.calls.find( (m) => (m.method === "update" || m.method === "postMessage") && m.args.text === "Here is my response", ); expect(answer).toBeDefined(); });
test("ignores an untagged thread reply that isn't answering the agent", async () => { const ts = `${Date.now()}.71`; slackTestState.repliesImpl = () => Promise.resolve({ messages: [ { user: "U1", text: "<@UBOT> update my status page", ts: "6.1" }, { user: "UBOT", bot_id: "B1", text: "Which page?", ts: "6.2" }, { user: "U2", text: "I'll check the logs", ts }, ], });
await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_untagged_other_${ts}`, event: { type: "message", text: "I'll check the logs", user: "U2", channel: "C1", channel_type: "channel", ts, thread_ts: "6.1", }, }); await new Promise((r) => setTimeout(r, 100));
expect(slackTestState.calls.length).toBe(0); });
test("replies in the agent pane without a mention", async () => { const ts = `${Date.now()}.5`; const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_dm_${ts}`, event: { type: "message", text: "which reports are open?", user: "U1", channel: "D1", channel_type: "im", ts, thread_ts: "1.1", }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100));
const status = slackTestState.calls.find( (m) => m.method === "assistant.threads.setStatus", ); expect(status?.args).toMatchObject({ channel_id: "D1", thread_ts: "1.1" });
// Native status replaces the "Thinking..." placeholder: the answer is // a single fresh message in the thread, never an update. const posts = slackTestState.calls.filter( (m) => m.method === "postMessage", ); expect(posts.length).toBe(1); expect(posts[0].args).toMatchObject({ channel: "D1", thread_ts: "1.1", text: "Here is my response", }); expect(slackTestState.calls.some((m) => m.method === "update")).toBe(false); });
test("ignores the agent pane thread root and edits", async () => { for (const subtype of ["assistant_app_thread", "message_changed"]) { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_dm_${subtype}_${Date.now()}`, event: { type: "message", subtype, text: "hello", user: "U1", channel: "D1", channel_type: "im", ts: `${Date.now()}.6`, }, }); expect(res.status).toBe(200); }
await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("posts the error in the agent pane when runAgent throws", async () => { slackTestState.runAgentOverride = () => Promise.reject(new Error("agent exploded"));
await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_dm_err_${Date.now()}`, event: { type: "message", text: "hello", user: "U1", channel: "D1", channel_type: "im", ts: `${Date.now()}.7`, thread_ts: "1.1", }, }); await new Promise((r) => setTimeout(r, 100));
const errorPost = slackTestState.calls.find( (m) => m.method === "postMessage" && typeof m.args.text === "string" && m.args.text.includes("Something went wrong"), ); expect(errorPost).toBeDefined(); });
test("greets in the thread on the legacy assistant_thread_started", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_thread_started_${Date.now()}`, event: { type: "assistant_thread_started", assistant_thread: { user_id: "U1", channel_id: "D1", thread_ts: "2.2", context: {}, }, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50));
// The greeting lands in the thread that was just opened, and states the // approval guarantee where it matters rather than only in App Home. const welcome = slackTestState.calls.find( (m) => m.method === "postMessage", ); expect(welcome?.args).toMatchObject({ channel: "D1", thread_ts: "2.2" }); expect(welcome?.args.text as string).toContain("Approve"); expect((welcome?.args.blocks as unknown[]).length).toBeGreaterThan(0); });
test("ignores events without channel", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_nochan_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", ts: `${Date.now()}.6`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("ignores events without timestamp", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_nots_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("ignores events without team_id", async () => { const res = await signAndPost(app, { type: "event_callback", event_id: `evt_noteam_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.7`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("ignores unsupported event types", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_unsupported_${Date.now()}`, event: { type: "channel_created", }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("ignores channel_join system messages", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_join_${Date.now()}`, event: { type: "message", subtype: "channel_join", text: "<@U1> has joined the channel", user: "U1", channel: "C1", ts: `${Date.now()}.10`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("ignores channel_leave system messages", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_leave_${Date.now()}`, event: { type: "message", subtype: "channel_leave", text: "<@U1> has left the channel", user: "U1", channel: "C1", ts: `${Date.now()}.11`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("ignores events with no event payload", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_noevent_${Date.now()}`, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.length).toBe(0); });
test("falls back to top-level message on cannot_reply_to_message", async () => { let callCount = 0; slackTestState.postMessageOverride = (args: Record<string, unknown>) => { callCount++; if (callCount === 1) { const err = new Error("An API error occurred: cannot_reply_to_message"); Object.assign(err, { code: "slack_webapi_platform_error", data: { ok: false, error: "cannot_reply_to_message" }, }); return Promise.reject(err); } slackTestState.calls.push({ method: "postMessage", args }); return Promise.resolve({ ts: "fallback.ts" }); };
const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_cantreply_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.20`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100));
const fallbackPost = slackTestState.calls.find( (m) => m.method === "postMessage" && !m.args.thread_ts, ); expect(fallbackPost).toBeDefined(); });
test("returns early on non-recoverable postMessage error", async () => { slackTestState.postMessageOverride = () => { const err = new Error("An API error occurred: channel_not_found"); Object.assign(err, { code: "slack_webapi_platform_error", data: { ok: false, error: "channel_not_found" }, }); return Promise.reject(err); };
const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_channotfound_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.21`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100));
const updateMessages = slackTestState.calls.filter( (m) => m.method === "update", ); expect(updateMessages.length).toBe(0); });
test("shows error message when runAgent throws", async () => { slackTestState.runAgentOverride = () => Promise.reject(new Error("agent exploded"));
const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_agenterr_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.30`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100));
// Delivered as a fresh message when streaming, or by overwriting the // "Thinking..." placeholder when it isn't โ either way the user sees it. const errorMessage = slackTestState.calls.find( (m) => (m.method === "update" || m.method === "postMessage") && typeof m.args.text === "string" && m.args.text.includes("Something went wrong"), ); expect(errorMessage).toBeDefined(); });
test("does not throw when both runAgent and error update fail", async () => { slackTestState.runAgentOverride = () => Promise.reject(new Error("agent exploded")); slackTestState.updateOverride = () => { const err = new Error("An API error occurred: channel_not_found"); Object.assign(err, { code: "slack_webapi_platform_error", data: { ok: false, error: "channel_not_found" }, }); return Promise.reject(err); };
const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_doublefail_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.31`, }, });
expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100)); // No unhandled rejection โ the .catch() in the error handler swallows it });});
describe("looksLikeUncardedDraft", () => { test("flags a prose draft that ends by asking permission", () => { expect( looksLikeUncardedDraft( `There's one status page ("Inter.link"). Here's the draft incident report I'll create:
**Title:** Traffic Forwarding Issue โ POP FRA1-DE**Status:** Investigating**Message:** We are currently investigating a traffic forwarding issue.
Shall I go ahead and publish this, or would you like to adjust anything?`, ), ).toBe(true); });
test("flags the other permission phrasings", () => { const draft = "*Title:* API outage\n*Message:* We're on it.\n"; expect(looksLikeUncardedDraft(`${draft}Want me to create this?`)).toBe( true, ); expect( looksLikeUncardedDraft(`${draft}Would you like me to post it now?`), ).toBe(true); });
test("ignores a plain answer that happens to end in a question", () => { expect( looksLikeUncardedDraft( "You have no active incidents. Want me to create one?", ), ).toBe(false); });
test("ignores a listing with field labels but no permission question", () => { expect( looksLikeUncardedDraft( "*Title:* API outage\n*Status:* investigating\n*Message:* We're on it.", ), ).toBe(false); });
test("ignores empty text", () => { expect(looksLikeUncardedDraft("")).toBe(false); });});
describe("isAnswerToAgent", () => { const starter = { user: "U1", text: "<@UBOT> open an incident", ts: "1" }; const question = { user: "UBOT", bot_id: "B1", text: "Which page?", ts: "2" };
test("answers the session starter replying right after the agent", () => { const thread = [starter, question, { user: "U1", text: "API", ts: "3" }]; expect(isAnswerToAgent(thread, { ts: "3", user: "U1" }, "UBOT")).toBe(true); });
test("ignores someone other than the session starter", () => { const thread = [starter, question, { user: "U2", text: "API", ts: "3" }]; expect(isAnswerToAgent(thread, { ts: "3", user: "U2" }, "UBOT")).toBe( false, ); });
test("ignores the starter once a human spoke after the agent", () => { const thread = [ starter, question, { user: "U2", text: "it's the API", ts: "3" }, { user: "U1", text: "yes, the API", ts: "4" }, ]; expect(isAnswerToAgent(thread, { ts: "4", user: "U1" }, "UBOT")).toBe( false, ); });
test("ignores threads where the agent was never mentioned", () => { const thread = [ { user: "U1", text: "anyone seeing errors?", ts: "1" }, question, { user: "U1", text: "API", ts: "3" }, ]; expect(isAnswerToAgent(thread, { ts: "3", user: "U1" }, "UBOT")).toBe( false, ); });
test("ignores other bots' messages as the previous message", () => { const thread = [ starter, { user: "UOTHER", bot_id: "B2", text: "Deploy finished", ts: "2" }, { user: "U1", text: "API", ts: "3" }, ]; expect(isAnswerToAgent(thread, { ts: "3", user: "U1" }, "UBOT")).toBe( false, ); });});
describe("streaming the agent's answer", () => { const app = createTestApp();
beforeEach(resetSlackTestState);
/** Drives the agent mock's `events` so the handler sees a real stream. */ function streamTurn( drive: (events: { onTextDelta(delta: string): Promise<void>; onToolCall(c: { id: string; toolName: string }): Promise<void>; onToolResult(r: { id: string; toolName: string }): Promise<void>; }) => Promise<void>, text = "All five monitors are healthy.", ) { slackTestState.runAgentOverride = async (options: unknown) => { // biome-ignore lint/suspicious/noExplicitAny: test double plumbing await drive((options as any).events); return { text, toolResults: [], finishReason: "stop", stepCount: 1, hitStepLimit: false, aborted: false, }; }; }
function mention(suffix: string) { return signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_stream_${suffix}`, event: { type: "app_mention", text: "<@UBOT> what's broken?", user: "U1", channel: "C1", ts: `${Date.now()}.${suffix}`, }, }); }
test("streams the answer instead of posting it", async () => { streamTurn(async (events) => { await events.onTextDelta("All five monitors "); await events.onTextDelta("are healthy."); });
const res = await mention("1"); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100));
const appended = slackTestState.calls .filter((m) => m.method === "stream.append") .map((m) => m.args.markdown_text); expect(appended).toEqual(["All five monitors ", "are healthy."]);
// The stream carried the answer, so it is finalized rather than re-posted. expect(slackTestState.calls.some((m) => m.method === "stream.stop")).toBe( true, ); expect(slackTestState.calls.some((m) => m.method === "postMessage")).toBe( false, ); });
test("reports each tool call as a task", async () => { streamTurn(async (events) => { await events.onToolCall({ id: "t1", toolName: "list_status_pages" }); await events.onToolResult({ id: "t1", toolName: "list_status_pages" }); await events.onTextDelta("Done."); });
await mention("2"); await new Promise((r) => setTimeout(r, 100));
const tasks = slackTestState.calls .filter((m) => m.method === "stream.append" && m.args.chunks) .flatMap((m) => m.args.chunks as Record<string, unknown>[]); expect(tasks).toEqual([ { type: "task_update", id: "t1", title: "Reading status pages", status: "in_progress", }, { type: "task_update", id: "t1", title: "Reading status pages", status: "complete", }, ]); });
test("falls back to the thread status when the workspace has no streaming", async () => { slackTestState.chatStreamEnabled = false; streamTurn(async () => {});
await mention("3"); await new Promise((r) => setTimeout(r, 100));
// No agent session and no stream leaves the thread status as the only // loading indicator; the answer is posted as a message of its own. expect( slackTestState.calls.some( (m) => m.method === "assistant.threads.setStatus", ), ).toBe(true); const posts = slackTestState.calls.filter( (m) => m.method === "postMessage", ); expect(posts.map((m) => m.args.text)).toEqual([ "All five monitors are healthy.", ]); expect(slackTestState.calls.some((m) => m.method === "update")).toBe(false); expect(slackTestState.calls.some((m) => m.method === "chatStream")).toBe( false, ); });
test("rewrites the partial message when the stream breaks mid-turn", async () => { // The first append lands, the second fails โ Slack is left holding half // an answer, so the whole answer has to replace it. slackTestState.streamAppendFailAfter = 1; streamTurn(async (events) => { await events.onTextDelta("All five "); await events.onTextDelta("monitors are healthy."); });
await mention("4"); await new Promise((r) => setTimeout(r, 100));
const rewrite = slackTestState.calls.find((m) => m.method === "update"); expect(rewrite?.args).toMatchObject({ channel: "C1", ts: "stream.ts", text: "All five monitors are healthy.", }); // A stream that was opened is closed even though a later append failed โ // otherwise Slack shows that message as streaming for good. expect(slackTestState.calls.some((m) => m.method === "stream.stop")).toBe( true, ); // Nothing is posted alongside it: one message, one answer. expect(slackTestState.calls.some((m) => m.method === "postMessage")).toBe( false, ); });
test("rewrites the message when the stream cannot be closed", async () => { // Slack has closed the stream on its side, so `stop` rejects โ the // half-written message is ours to finish. slackTestState.streamStopFail = true; streamTurn(async (events) => { await events.onTextDelta("All five monitors are healthy."); });
await mention("5"); await new Promise((r) => setTimeout(r, 100));
const rewrite = slackTestState.calls.find((m) => m.method === "update"); expect(rewrite?.args).toMatchObject({ channel: "C1", ts: "stream.ts", text: "All five monitors are healthy.", }); });
test("names tasks after the tool's verb", () => { expect(toolTaskTitle("list_status_pages")).toBe("Reading status pages"); expect(toolTaskTitle("get_monitor_status")).toBe("Reading monitor status"); expect(toolTaskTitle("create_status_report")).toBe( "Drafting status report", ); expect(toolTaskTitle("search_docs")).toBe("Searching docs"); // An unknown verb still reads as words rather than a tool name. expect(toolTaskTitle("frobnicate_widgets")).toBe("frobnicate widgets"); expect(toolTaskTitle("ping")).toBe("ping"); });});
describe("running turns", () => { test("aborts only the thread it was asked about", () => { const turn = startTurn("C_RT", "1.1"); expect(abortTurn("C_RT", "9.9")).toBe(false); expect(turn.signal.aborted).toBe(false);
expect(abortTurn("C_RT", "1.1")).toBe(true); expect(turn.signal.aborted).toBe(true);
endTurn("C_RT", "1.1", turn); expect(abortTurn("C_RT", "1.1")).toBe(false); });
test("a finished turn does not deregister the one that replaced it", () => { const first = startTurn("C_RT2", "2.2"); const second = startTurn("C_RT2", "2.2"); endTurn("C_RT2", "2.2", first);
expect(abortTurn("C_RT2", "2.2")).toBe(true); expect(second.signal.aborted).toBe(true); endTurn("C_RT2", "2.2", second); });
test("stops every turn running on the thread", () => { // Two messages sent in quick succession overlap; stop means stop the // thread, not only its newest turn. const first = startTurn("C_RT3", "3.3"); const second = startTurn("C_RT3", "3.3");
expect(abortTurn("C_RT3", "3.3")).toBe(true); expect(first.signal.aborted).toBe(true); expect(second.signal.aborted).toBe(true);
endTurn("C_RT3", "3.3", first); endTurn("C_RT3", "3.3", second); expect(abortTurn("C_RT3", "3.3")).toBe(false); });});
describe("stopping a turn", () => { const app = createTestApp();
beforeEach(() => { resetSlackTestState(); // A workspace with agent sessions โ the surface the stop button lives on. slackTestState.sessionStatusOverride = null; });
function stopEvent(channel: string, threadTs: string) { return signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_stop_${channel}_${threadTs}`, event: { type: "agent_session_stopped", channel, thread_ts: threadTs, user: "U1", streaming_message_ts: [], event_ts: "1.1", }, }); }
function activeStatusCalls() { return slackTestState.calls.filter( (m) => m.method === "agents.sessions.setStatus" && m.args.status === "active", ); }
test("aborts the run, confirms the stop, and clears the status", async () => { let signal: AbortSignal | undefined; slackTestState.runAgentOverride = (options: unknown) => { signal = (options as { signal: AbortSignal }).signal; return new Promise((resolve) => { signal?.addEventListener("abort", () => resolve({ text: "Looking at the API monit", toolResults: [], finishReason: "abort", stepCount: 0, hitStepLimit: false, aborted: true, }), ); }); };
const ts = "6001.1"; await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_stopme_${ts}`, event: { type: "app_mention", text: "<@UBOT> what's broken?", user: "U1", channel: "C_STOP", ts, }, }); await new Promise((r) => setTimeout(r, 50)); expect(signal).toBeDefined(); expect(signal?.aborted).toBe(false);
await stopEvent("C_STOP", ts); await new Promise((r) => setTimeout(r, 100));
expect(signal?.aborted).toBe(true); // Twice over: the aborted turn's own cleanup, and the stop branch, which // can't rely on that turn โ it may be running on another instance. const cleared = activeStatusCalls().filter( (m) => m.args.channel_id === "C_STOP" && m.args.thread_ts === ts, ); expect(cleared.length).toBeGreaterThanOrEqual(2);
const notice = slackTestState.calls.find( (m) => m.method === "postMessage" && typeof m.args.text === "string" && m.args.text.includes("Stopped"), ); expect(notice).toBeDefined();
// The half-written answer is never delivered as if it were finished. const answer = slackTestState.calls.find( (m) => typeof m.args.text === "string" && m.args.text.includes("Looking at the API monit"), ); expect(answer).toBeUndefined(); });
test("clears the status even when no turn is running here", async () => { await stopEvent("C_STOP2", "7001.1"); await new Promise((r) => setTimeout(r, 50));
// Nothing to abort โ another instance may hold the turn โ but the user // still has to get out of the loading state. expect(activeStatusCalls()).toHaveLength(1); expect(activeStatusCalls()[0].args).toMatchObject({ channel_id: "C_STOP2", thread_ts: "7001.1", }); });});
describe("titling a thread", () => { const app = createTestApp(); const redisStore = (globalThis as Record<string, unknown>) .__testRedisStore as Map<string, string>;
beforeEach(() => { resetSlackTestState(); redisStore.clear(); });
function renameCalls() { return slackTestState.calls.filter( (m) => m.method === "agents.sessions.rename", ); }
function paneMessage(ts: string, text: string, threadTs?: string) { return signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_title_${ts}`, event: { type: "message", channel_type: "im", text, user: "U1", channel: "D_TITLE", ts, ...(threadTs ? { thread_ts: threadTs } : {}), }, }); }
test("names the pane thread after what the user asked", async () => { await paneMessage("8001.1", "which reports are currently open?"); await new Promise((r) => setTimeout(r, 100));
expect(renameCalls()).toHaveLength(1); expect(renameCalls()[0].args).toMatchObject({ channel_id: "D_TITLE", thread_ts: "8001.1", title: "which reports are currently open?", }); });
test("names it once and leaves it alone after that", async () => { await paneMessage("8002.1", "is the checkout monitor healthy?"); await new Promise((r) => setTimeout(r, 100)); expect(renameCalls()).toHaveLength(1);
// A second turn on the same thread: the subject hasn't changed, and the // name shouldn't follow whatever was asked next. await paneMessage("8002.2", "and what about billing?", "8002.1"); await new Promise((r) => setTimeout(r, 100)); expect(renameCalls()).toHaveLength(1); });
test("stops renaming once a person has named it", async () => { await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: "evt_renamed_8003", event: { type: "agent_session_title_changed", channel: "D_TITLE", thread_ts: "8003.1", user: "U1", title: "Tuesday's Stripe outage", event_ts: "1.1", }, }); await new Promise((r) => setTimeout(r, 50));
await paneMessage("8003.2", "any update on this?", "8003.1"); await new Promise((r) => setTimeout(r, 100));
expect(renameCalls()).toHaveLength(0); });
test("leaves channel threads alone", async () => { // `agents.sessions.rename` also renames the channel for session channels โ // not worth risking on a shared incident channel for a name nobody lists. await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: "evt_title_channel", event: { type: "app_mention", text: "<@UBOT> what's broken?", user: "U1", channel: "C_TITLE", ts: "8004.1", }, }); await new Promise((r) => setTimeout(r, 100));
expect(renameCalls()).toHaveLength(0); });
test("keeps the answer when renaming fails", async () => { slackTestState.renameOverride = () => Promise.reject(new Error("feature_disabled"));
await paneMessage("8005.1", "which reports are open?"); await new Promise((r) => setTimeout(r, 100));
const answered = slackTestState.calls.some( (m) => typeof m.args.text === "string" && m.args.text.includes("Here is my response"), ); expect(answered).toBe(true); // A failed rename must not mark the thread as named. expect(redisStore.has("slack:title:D_TITLE:8005.1")).toBe(false); });});
describe("greeting on first contact", () => { const app = createTestApp(); const redisStore = (globalThis as Record<string, unknown>) .__testRedisStore as Map<string, string>;
beforeEach(() => { resetSlackTestState(); redisStore.clear(); });
function homeOpened(tab: string, userId = "U1") { return signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_home_${tab}_${userId}_${Math.random()}`, event: { type: "app_home_opened", user: userId, channel: "D_WELCOME", tab, event_ts: "1.1", }, }); }
function welcomes() { return slackTestState.calls.filter( (m) => m.method === "postMessage" && typeof m.args.text === "string" && m.args.text.includes("Approve"), ); }
test("greets when the Messages tab is opened", async () => { await homeOpened("messages"); await settleBackgroundTasks();
expect(welcomes()).toHaveLength(1); // Top-level in the DM: the agent experience has no thread to greet into. expect(welcomes()[0].args.thread_ts).toBeUndefined(); });
test("greets a person once, however often they open it", async () => { await homeOpened("messages"); await settleBackgroundTasks(); await homeOpened("messages"); await settleBackgroundTasks();
expect(welcomes()).toHaveLength(1); });
test("greets each person separately", async () => { await homeOpened("messages", "U1"); await settleBackgroundTasks(); await homeOpened("messages", "U2"); await settleBackgroundTasks();
expect(welcomes()).toHaveLength(2); });
test("publishes the home view on the Home tab without greeting", async () => { await homeOpened("home"); await settleBackgroundTasks();
expect(slackTestState.calls.some((m) => m.method === "views.publish")).toBe( true, ); expect(welcomes()).toHaveLength(0); });
test("does not mark someone greeted when the greeting fails", async () => { slackTestState.postMessageOverride = () => Promise.reject(new Error("channel_not_found"));
await homeOpened("messages"); await settleBackgroundTasks();
// Otherwise a transient failure costs them the greeting permanently. expect(redisStore.has("slack:greeted:T_KNOWN:U1")).toBe(false); });});
describe("the channel the user is viewing", () => { const app = createTestApp(); const redisStore = (globalThis as Record<string, unknown>) .__testRedisStore as Map<string, string>;
beforeEach(() => { resetSlackTestState(); redisStore.clear(); });
function contextChanged( entities: Array<Record<string, string>> | undefined, userId = "U1", ) { return signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_ctx_${userId}_${Math.random()}`, event: { type: "app_context_changed", context: entities ? { entities } : {}, }, authorizations: [ { user_id: "B0", is_bot: true }, { user_id: userId, is_bot: false }, ], }); }
/** Captures what the handler handed the agent for this turn. */ function captureAgentOptions() { const seen: { tools?: Record<string, unknown>; contextNote?: string }[] = []; slackTestState.runAgentOverride = (options: unknown) => { seen.push(options as { contextNote?: string }); return Promise.resolve({ text: "Here is my response", toolResults: [], finishReason: "stop", stepCount: 1, hitStepLimit: false, aborted: false, }); }; return seen; }
test("remembers it for the authorizing human", async () => { await contextChanged([ { type: "slack#/types/channel_id", value: "C_INCIDENT" }, ]); await new Promise((r) => setTimeout(r, 50));
expect(redisStore.get("slack:context:T_KNOWN:U1")).toBe("C_INCIDENT"); // Never attributed to the bot authorization. expect(redisStore.has("slack:context:T_KNOWN:B0")).toBe(false); });
test("forgets it when the context empties", async () => { await contextChanged([ { type: "slack#/types/channel_id", value: "C_INCIDENT" }, ]); await new Promise((r) => setTimeout(r, 50)); await contextChanged(undefined); await new Promise((r) => setTimeout(r, 50));
// A channel they left is worse than no context at all. expect(redisStore.has("slack:context:T_KNOWN:U1")).toBe(false); });
test("offers the channel to the agent on a pane turn", async () => { await contextChanged([ { type: "slack#/types/channel_id", value: "C_INCIDENT" }, ]); await new Promise((r) => setTimeout(r, 50));
const seen = captureAgentOptions(); await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: "evt_ctx_turn", event: { type: "message", channel_type: "im", text: "draft an update for this", user: "U1", channel: "D_CTX", ts: "9101.1", }, }); await new Promise((r) => setTimeout(r, 100));
expect(seen).toHaveLength(1); expect(seen[0].contextNote).toContain("<#C_INCIDENT>"); expect(Object.keys(seen[0].tools ?? {})).toContain("read_slack_channel"); // Nothing is read until the model decides the request calls for it. expect( slackTestState.calls.some((m) => m.method === "conversations.history"), ).toBe(false); });
test("leaves channel turns alone", async () => { await contextChanged([ { type: "slack#/types/channel_id", value: "C_INCIDENT" }, ]); await new Promise((r) => setTimeout(r, 50));
const seen = captureAgentOptions(); await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: "evt_ctx_channel_turn", event: { type: "app_mention", text: "<@UBOT> what's broken?", user: "U1", channel: "C_OTHER", ts: "9102.1", }, }); await new Promise((r) => setTimeout(r, 100));
// In a channel the agent already has the thread it was called into. expect(seen).toHaveLength(1); expect(seen[0].contextNote).toBeUndefined(); expect(seen[0].tools).toBeUndefined(); });});
describe("confirmation cards", () => { const app = createTestApp();
beforeEach(resetSlackTestState);
test("posts a card for every draft in the turn", async () => { const draft = (toolName: string, input: Record<string, unknown>) => ({ toolName, result: { needsConfirmation: true, toolName, input, displayInput: input }, }); slackTestState.runAgentOverride = () => Promise.resolve({ text: "Two cards for you.", toolResults: [ draft("update_status_report", { statusReportId: 7, title: "Elevated API error rate", }), draft("add_status_report_update", { statusReportId: 7, status: "investigating", message: "Some requests return 500s.", }), ], finishReason: "stop", stepCount: 2, hitStepLimit: false, aborted: false, });
await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_two_cards_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> rename it and post an update", user: "U1", channel: "C1", ts: `${Date.now()}.30`, }, }); await new Promise((r) => setTimeout(r, 100));
const actionIds = slackTestState.calls .filter((m) => m.method === "update" && Array.isArray(m.args.blocks)) .flatMap((m) => ( m.args.blocks as { type: string; elements?: { action_id: string }[]; }[] ) .filter((b) => b.type === "actions") .flatMap((b) => b.elements?.map((e) => e.action_id) ?? []), ); // Rename: approve + cancel. Update: approve, approve & notify, cancel. expect(actionIds.filter((id) => id.startsWith("cancel_"))).toHaveLength(2); expect( actionIds.filter((id) => id.startsWith("approve_flag_")), ).toHaveLength(1); // The second card is a message of its own, not an overwrite of the first. expect( slackTestState.calls.filter((m) => m.method === "postMessage").length, ).toBeGreaterThanOrEqual(2); });});
describe("members only", () => { const app = createTestApp(); const redisStore = (globalThis as Record<string, unknown>) .__testRedisStore as Map<string, string>;
beforeEach(() => { resetSlackTestState(); slackTestState.usersInfoImpl = () => Promise.resolve({ ok: true, user: { profile: {} } }); });
const unlinked = () => `U_UNLINKED_${crypto.randomUUID()}`; let agentRuns = 0;
test("a mention from an unlinked user gets a link card, not an answer", async () => { agentRuns = 0; slackTestState.runAgentOverride = () => { agentRuns++; return Promise.resolve({ text: "should not run", toolResults: [], finishReason: "stop", stepCount: 1, hitStepLimit: false, aborted: false, }); }; const user = unlinked(); await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_unlinked_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> what is down?", user, channel: "C1", channel_type: "channel", ts: `${Date.now()}.71`, }, }); const card = await waitForCall("postEphemeral"); expect(card?.args.user).toBe(user); expect(String(card?.args.text)).toContain("Link your openstatus account"); const blocks = card?.args.blocks as { type: string; elements?: { url?: string }[]; }[]; const url = blocks.find((b) => b.type === "actions")?.elements?.[0] ?.url as string; expect(url).toContain("/settings/integrations/slack/link?token="); expect(agentRuns).toBe(0); });
test("the link card is sent once per window on passive surfaces", async () => { const user = unlinked(); for (const suffix of ["81", "82"]) { await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_unlinked_twice_${suffix}_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user, channel: "C1", channel_type: "channel", ts: `${Date.now()}.${suffix}`, }, }); await settleBackgroundTasks(); } expect( slackTestState.calls.filter((c) => c.method === "postEphemeral"), ).toHaveLength(1); expect(redisStore.has(`slack:linkcard:T_KNOWN:${user}`)).toBe(true); });
test("an unlinked user in the agent pane gets the card in the thread", async () => { const user = unlinked(); await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_unlinked_im_${Date.now()}`, event: { type: "message", text: "hi", user, channel: "D1", channel_type: "im", ts: `${Date.now()}.91`, }, }); const card = await waitForCall("postMessage"); expect(String(card?.args.text)).toContain("Link your openstatus account"); expect(card?.args.channel).toBe("D1"); });
test("the home tab shows the link view to an unlinked user", async () => { await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_unlinked_home_${Date.now()}`, event: { type: "app_home_opened", tab: "home", user: unlinked() }, }); const publish = await waitForCall("views.publish"); const view = publish?.args.view as { blocks: { type: string }[] }; expect(view.blocks.some((b) => b.type === "actions")).toBe(true); });});
describe("hardening", () => { const app = createTestApp(); const redisStore = (globalThis as Record<string, unknown>) .__testRedisStore as Map<string, string>;
beforeEach(resetSlackTestState);
test("a workspace without the Slack agent gets a plan notice", async () => { slackTestState.resolveWorkspace = () => Promise.resolve({ workspace: { id: 1, name: "Free", slug: "free", plan: "free", limits: {}, }, botToken: "xoxb-test", botUserId: "UBOT", }); const user = `U_FREE_${crypto.randomUUID()}`; await signAndPost(app, { type: "event_callback", team_id: "T_FREE", event_id: `evt_free_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hi", user, channel: "C1", channel_type: "channel", ts: `${Date.now()}.61`, }, }); const notice = await waitForCall("postEphemeral"); expect(String(notice?.args.text)).toContain("/settings/billing"); // The plan notice must not use up the account-link card's window. expect(redisStore.has(`slack:linkcard:T_FREE:${user}`)).toBe(false); });
test("the same event id is processed once, across instances", async () => { const eventId = `evt_dup_${Date.now()}`; const body = { type: "event_callback", team_id: "T_KNOWN", event_id: eventId, event: { type: "app_mention", text: "<@UBOT> status?", user: "U1", channel: "C1", channel_type: "channel", ts: `${Date.now()}.62`, }, }; await signAndPost(app, body); await signAndPost(app, body); expect(redisStore.has(`slack:event:${eventId}`)).toBe(true); await new Promise((r) => setTimeout(r, 150)); const thinking = slackTestState.calls.filter( (c) => c.method === "chatStream" || (c.method === "postMessage" && String(c.args.text).includes("Thinking")), ); expect(thinking).toHaveLength(1); });
test("a stop from another instance aborts the running turn", async () => { const turn = startTurn("C_REMOTE", "7.7"); await new Promise((r) => setTimeout(r, 10)); await broadcastStop("C_REMOTE", "7.7"); await new Promise((r) => setTimeout(r, 1_200)); expect(turn.signal.aborted).toBe(true); endTurn("C_REMOTE", "7.7", turn); });
test("an earlier stop does not abort a newer turn", async () => { await broadcastStop("C_OLD", "8.8"); const turn = startTurn("C_OLD", "8.8"); await new Promise((r) => setTimeout(r, 1_200)); expect(turn.signal.aborted).toBe(false); endTurn("C_OLD", "8.8", turn); });
async function seedSlackInstall() { const teamId = `T_REVOKED_${crypto.randomUUID()}`; const { workspace } = await createTestWorkspace(); const row = await db .insert(integration) .values({ name: "slack-agent", workspaceId: workspace.id, externalId: teamId, data: {}, }) .returning() .get(); return { teamId, id: row.id }; }
async function revokeTokens( teamId: string, tokens: Record<string, string[]>, ) { const res = await signAndPost(app, { type: "event_callback", team_id: teamId, event_id: `evt_revoked_${crypto.randomUUID()}`, event: { type: "tokens_revoked", tokens }, }); expect(res.status).toBe(200); await settleBackgroundTasks(); }
function findIntegration(id: number) { return db.select().from(integration).where(eq(integration.id, id)).get(); }
test("tokens_revoked without a bot token leaves the install alone", async () => { const install = await seedSlackInstall(); try { await revokeTokens(install.teamId, { oauth: ["U1"] }); expect(await findIntegration(install.id)).toBeDefined(); } finally { await db.delete(integration).where(eq(integration.id, install.id)); } });
test("tokens_revoked with a bot token uninstalls", async () => { const install = await seedSlackInstall(); try { await revokeTokens(install.teamId, { bot: ["UBOT"] }); expect(await findIntegration(install.id)).toBeUndefined(); } finally { await db.delete(integration).where(eq(integration.id, install.id)); } });});
describe("reconnect banner", () => { const app = createTestApp();
beforeEach(resetSlackTestState);
test("the home tab asks an old install to reconnect", async () => { slackTestState.resolveWorkspace = () => Promise.resolve({ workspace: { id: 1, name: "Test Workspace", slug: "test", plan: "team", limits: { "slack-agent": true }, }, botToken: "xoxb-test", botUserId: "UBOT", scopes: "chat:write,users:read,users:read.email", }); await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_reconnect_${Date.now()}`, event: { type: "app_home_opened", tab: "home", user: "U1" }, }); const publish = await waitForCall("views.publish"); const view = publish?.args.view as { blocks: { type: string; text?: { text: string } }[]; }; expect(view.blocks[0].text?.text).toContain("Reconnect openstatus"); });});
describe("incident channel context", () => { const app = createTestApp();
beforeEach(resetSlackTestState);
test("a mention in a bound channel tells the agent which incident it is", async () => { const channelId = `C_CTX_${crypto.randomUUID()}`; const [row] = await db .insert(incident) .values({ workspaceId: 1, title: "Context incident", severity: "major", declaredAt: new Date(), startedAt: new Date(), slackTeamId: "T_KNOWN", slackChannelId: channelId, }) .returning(); let contextNote: string | undefined; slackTestState.runAgentOverride = (options) => { contextNote = (options as { contextNote?: string } | undefined) ?.contextNote; return Promise.resolve({ text: "ok", toolResults: [], finishReason: "stop", stepCount: 1, hitStepLimit: false, aborted: false, }); }; try { await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_ctx_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> note that we rolled back", user: "U1", channel: channelId, channel_type: "channel", ts: `${Date.now()}.71`, }, }); const deadline = Date.now() + 2000; while (contextNote === undefined && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 20)); } expect(contextNote).toContain(`id ${row.id}`); expect(contextNote).toContain("Context incident"); } finally { await db.delete(incident).where(eq(incident.id, row.id)); } });});
describe("incident channel events", () => { const app = createTestApp(); let incidentId: number; let channelId: string;
beforeEach(async () => { resetSlackTestState(); slackTestState.reactionsGetImpl = () => Promise.resolve({ ok: true, message: {} }); channelId = `C_PIN_${crypto.randomUUID()}`; const [row] = await db .insert(incident) .values({ workspaceId: 1, title: "Pinned incident", severity: "major", declaredAt: new Date(), startedAt: new Date(), slackTeamId: "T_KNOWN", slackChannelId: channelId, }) .returning(); incidentId = row.id; });
afterEach(async () => { await db .delete(incidentEvent) .where(eq(incidentEvent.incidentId, incidentId)); await db.delete(incident).where(eq(incident.id, incidentId)); });
async function notes() { return db .select() .from(incidentEvent) .where(eq(incidentEvent.incidentId, incidentId)) .all(); }
function pin(ts: string, user = "U1") { return signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_pin_${crypto.randomUUID()}`, event: { type: "reaction_added", user, reaction: "pushpin", item: { type: "message", channel: channelId, ts }, }, }); }
test("a ๐ copies the message onto the timeline and confirms with โ
", async () => { slackTestState.historyImpl = () => Promise.resolve({ messages: [{ ts: "500.1", text: "Rolled back to v41", user: "U2" }], }); await pin("500.1"); await waitForCall("reactions.add"); const rows = await notes(); expect(rows).toHaveLength(1); expect(rows[0].message).toContain("Rolled back to v41"); expect(rows[0].message).toContain("https://slack.test/archives/"); });
test("a message already confirmed is not noted twice", async () => { slackTestState.historyImpl = () => Promise.resolve({ messages: [{ ts: "501.1", text: "Twice" }] }); slackTestState.reactionsGetImpl = () => Promise.resolve({ ok: true, message: { reactions: [{ name: "white_check_mark", users: ["UBOT"] }], }, }); await pin("501.1"); expect(await waitForCall("reactions.get")).toBeDefined(); // The handler returns right after reactions.get; give it a tick to settle. await new Promise((r) => setTimeout(r, 50)); expect(slackTestState.calls.some((c) => c.method === "reactions.add")).toBe( false, ); expect(await notes()).toHaveLength(0); });
test("two ๐ racing on one message note it once", async () => { slackTestState.historyImpl = () => Promise.resolve({ messages: [{ ts: "502.1", text: "Once" }] }); await Promise.all([pin("502.1"), pin("502.1", "U3")]); await waitForCall("reactions.add"); await new Promise((r) => setTimeout(r, 50)); expect(await notes()).toHaveLength(1); });
test("a pinned thread reply is found through the thread", async () => { slackTestState.historyImpl = () => Promise.resolve({ messages: [{ ts: "400.0", text: "parent" }] }); let repliesArgs: Record<string, unknown> | undefined; slackTestState.repliesImpl = (args) => { repliesArgs = args; return Promise.resolve({ messages: [ { ts: "400.0", text: "parent" }, { ts: "400.5", text: "the reply" }, ], }); }; await pin("400.5"); await waitForCall("reactions.add"); const rows = await notes(); expect(rows[0]?.message).toContain("the reply"); // Slack returns the parent first, so the range must start at the reply. expect(repliesArgs).toMatchObject({ oldest: "400.5", latest: "400.5" }); });
test("archiving the channel unbinds the incident", async () => { await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_archive_${crypto.randomUUID()}`, event: { type: "channel_archive", channel: channelId, user: "U1" }, }); const deadline = Date.now() + 2000; let bound: string | null = channelId; while (bound && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 20)); const row = await db .select({ slackChannelId: incident.slackChannelId }) .from(incident) .where(eq(incident.id, incidentId)) .get(); bound = row?.slackChannelId ?? null; } expect(bound).toBeNull(); });});