Skip to content

Commit 183b9fe

Browse files
committed
feat(dev): block kit regression harness behind OPENTAG_BLOCK_DEBUG_TEST
Posts every Slack block, element and composition object the SDK supports as its own message, each in its own try/catch, then a summary of delivered / refused (with Slack's error) / deliberately skipped (with the reason). One message per item is load-bearing rather than stylistic: Slack accepts a message whole or not at all, and a refusal ends the turn's delivery, so a single bad payload in a shared message would hide every other verdict. For the same reason the one item Slack always refuses in a message context (rich_text_input) runs in a deferred tail, after the summary, so the healthy items still report. The item list is derived from SLACK_NATIVE_MANIFEST and diffed by a test, so a block added upstream fails the suite instead of silently going untested. Gated by OPENTAG_BLOCK_DEBUG_TEST: with the variable unset no hook is created and mentions take exactly the path they take without this branch.
1 parent 8d744bd commit 183b9fe

3 files changed

Lines changed: 1623 additions & 0 deletions

File tree

app/channel.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { managedRunInput, reportRecoverableError } from "./channel-helpers.js";
88
import { appCommands } from "./commands/index.js";
99
import { IssueCard, IssueList, PageList } from "./components/index.js";
1010
import { createAppContext } from "./context/app-context.js";
11+
import { createBlockTestRunHook } from "./dev/block-testrun.js";
1112
import { DEFAULT_AGENT_DISPLAY_NAME } from "./env.js";
1213
import { ConfirmWrite } from "./human-in-the-loop/index.js";
1314
import { parseConfirmWriteInterrupt } from "./interrupt.js";
@@ -76,9 +77,16 @@ export function createOpenTagChannel(
7677
}
7778
};
7879

80+
// Developer-only Block Kit regression harness, and `undefined` unless
81+
// OPENTAG_BLOCK_DEBUG_TEST=1 — with the variable unset there is no hook to
82+
// call and mentions take exactly the path they take without it.
83+
const blockTestRun = createBlockTestRunHook();
84+
7985
channel.onMention(async ({ thread, message }) => {
8086
if (message.actor.kind === "bot" || message.actor.kind === "app") return;
8187

88+
if (blockTestRun && (await blockTestRun({ thread, message }))) return;
89+
8290
if (await thread.isSubscribed()) {
8391
await runAgentSafely({ thread, message }, [unsubscribeThreadTool]);
8492
return;
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
ActionRegistry,
4+
InMemoryActionStore,
5+
renderToIR,
6+
} from "@copilotkit/channels";
7+
import {
8+
SLACK_NATIVE_MANIFEST,
9+
renderBlockKit,
10+
} from "@copilotkit/channels/slack";
11+
import {
12+
BLOCK_TESTRUN_ENV_VAR,
13+
BLOCK_TESTRUN_ITEMS,
14+
BLOCK_TESTRUN_SKIPPED,
15+
BLOCK_TESTRUN_TRIGGER,
16+
createBlockTestRunHook,
17+
handleBlockTestRun,
18+
isBlockTestRunEnabled,
19+
parseTestRunFilter,
20+
runBlockTestRun,
21+
summaryText,
22+
} from "../block-testrun.js";
23+
24+
const manifestKeys = SLACK_NATIVE_MANIFEST.map(
25+
(entry) => `${entry.kind}:${entry.type}`,
26+
).sort();
27+
28+
describe("block test-run coverage", () => {
29+
// The point of deriving from the manifest: an entry added to the SDK's
30+
// catalog fails here instead of quietly going untested for a release.
31+
it("posts exactly one item per catalog entry", () => {
32+
expect(BLOCK_TESTRUN_ITEMS.map((item) => item.key).sort()).toEqual(
33+
manifestKeys,
34+
);
35+
});
36+
37+
it("accounts for the entries Slack refuses from a message", () => {
38+
// These are absent from the manifest on purpose, so they can only be
39+
// covered as declared skips — never as items.
40+
expect(BLOCK_TESTRUN_SKIPPED.map((skip) => skip.key).sort()).toEqual([
41+
"block:alert",
42+
"block:file",
43+
]);
44+
for (const skip of BLOCK_TESTRUN_SKIPPED) {
45+
expect(skip.reason.length).toBeGreaterThan(0);
46+
expect(manifestKeys).not.toContain(skip.key);
47+
}
48+
});
49+
50+
it("labels every item", () => {
51+
for (const item of BLOCK_TESTRUN_ITEMS) {
52+
expect(item.label.length).toBeGreaterThan(0);
53+
}
54+
});
55+
56+
// Serialization failures are cheaper to find here than in a 70-second live
57+
// run: the codec rejects a missing required field before Slack ever sees it.
58+
it.each(BLOCK_TESTRUN_ITEMS.map((item) => [item.key, item] as const))(
59+
"%s serializes to Block Kit",
60+
(_key, item) => {
61+
expect(() => renderBlockKit(renderToIR([item.node]))).not.toThrow();
62+
},
63+
);
64+
});
65+
66+
/**
67+
* The return path, as far as it can be proved without a human clicking: an
68+
* element whose handler never binds is delivered without an `action_id`, and
69+
* Slack then has nothing to call back with — the control is silent by
70+
* construction rather than by defect. This binds each item the way `thread.post`
71+
* does and asserts the id survives into the Block Kit that reaches Slack.
72+
*/
73+
async function actionIds(node: unknown): Promise<string[]> {
74+
const registry = new ActionRegistry({ store: new InMemoryActionStore() });
75+
const bound = await registry.bindRenderable([node] as never, "conv:test");
76+
const found: string[] = [];
77+
78+
const walk = (value: unknown): void => {
79+
if (Array.isArray(value)) return value.forEach(walk);
80+
if (typeof value !== "object" || value === null) return;
81+
for (const [name, child] of Object.entries(value)) {
82+
if (name === "action_id" && typeof child === "string") found.push(child);
83+
walk(child);
84+
}
85+
};
86+
walk(renderBlockKit(bound.root));
87+
return found;
88+
}
89+
90+
describe("block test-run return path", () => {
91+
// Guards the guard: if a refactor dropped the handlers, the per-item checks
92+
// below would all pass by being generated from an empty list.
93+
it("attaches a handler to most of the catalog", () => {
94+
expect(
95+
BLOCK_TESTRUN_ITEMS.filter((item) => hasHandler(item.node)).length,
96+
).toBeGreaterThan(25);
97+
});
98+
99+
it.each(
100+
BLOCK_TESTRUN_ITEMS.filter((item) => hasHandler(item.node)).map(
101+
(item) => [item.key, item] as const,
102+
),
103+
)("%s reaches Slack with an action_id", async (_key, item) => {
104+
expect(await actionIds(item.node)).not.toHaveLength(0);
105+
});
106+
});
107+
108+
/** True when the payload carries a handler somewhere in its prop tree. */
109+
function hasHandler(value: unknown): boolean {
110+
if (Array.isArray(value)) return value.some(hasHandler);
111+
if (typeof value !== "object" || value === null) return false;
112+
return Object.entries(value).some(
113+
([name, child]) =>
114+
(["onClick", "onSelect", "onSubmit"].includes(name) &&
115+
typeof child === "function") ||
116+
hasHandler(child),
117+
);
118+
}
119+
120+
describe("block test-run gate", () => {
121+
const thread = () => ({ post: vi.fn(async () => ({ id: "m1" })) });
122+
123+
// The gate has to make the harness *absent*, not present-and-declining: with
124+
// the flag unset `app/channel.tsx` must have no hook to call, so a mention
125+
// takes exactly the path it takes without this file.
126+
it("installs no hook at all when the env var is unset", () => {
127+
expect(createBlockTestRunHook({})).toBeUndefined();
128+
for (const value of ["0", "true", "yes", ""]) {
129+
expect(
130+
createBlockTestRunHook({ [BLOCK_TESTRUN_ENV_VAR]: value }),
131+
).toBeUndefined();
132+
}
133+
});
134+
135+
it("installs a hook that declines mentions without the trigger", async () => {
136+
const hook = createBlockTestRunHook({ [BLOCK_TESTRUN_ENV_VAR]: "1" });
137+
expect(hook).toBeDefined();
138+
139+
const target = thread();
140+
await expect(
141+
hook?.({ thread: target, message: { text: "triage my open issues" } }),
142+
).resolves.toBe(false);
143+
expect(target.post).not.toHaveBeenCalled();
144+
});
145+
146+
it("is inert when the env var is unset", async () => {
147+
const target = thread();
148+
149+
await expect(
150+
handleBlockTestRun(
151+
{
152+
thread: target,
153+
message: { text: `please ${BLOCK_TESTRUN_TRIGGER}` },
154+
},
155+
{},
156+
),
157+
).resolves.toBe(false);
158+
expect(target.post).not.toHaveBeenCalled();
159+
});
160+
161+
it("is inert when the env var is not exactly 1", async () => {
162+
for (const value of ["0", "true", "yes", ""]) {
163+
expect(isBlockTestRunEnabled({ [BLOCK_TESTRUN_ENV_VAR]: value })).toBe(
164+
false,
165+
);
166+
}
167+
});
168+
169+
it("ignores a mention without the trigger phrase even when armed", async () => {
170+
const target = thread();
171+
172+
await expect(
173+
handleBlockTestRun(
174+
{ thread: target, message: { text: "triage my open issues" } },
175+
{ [BLOCK_TESTRUN_ENV_VAR]: "1" },
176+
),
177+
).resolves.toBe(false);
178+
expect(target.post).not.toHaveBeenCalled();
179+
});
180+
});
181+
182+
describe("block test-run filter", () => {
183+
it("selects the entries whose key contains the token", () => {
184+
expect(parseTestRunFilter("test-run element:workflow_button")).toBe(
185+
"element:workflow_button",
186+
);
187+
expect(parseTestRunFilter("test-run rich_text_input")).toBe(
188+
"rich_text_input",
189+
);
190+
});
191+
192+
// Prose after the trigger is the common case, and a token that names nothing
193+
// must not narrow the run to zero items and call that a pass.
194+
it("ignores trailing prose that names no catalog entry", () => {
195+
expect(parseTestRunFilter("test-run — gate check B")).toBeUndefined();
196+
expect(parseTestRunFilter("test-run please")).toBeUndefined();
197+
expect(parseTestRunFilter("test-run")).toBeUndefined();
198+
expect(parseTestRunFilter(undefined)).toBeUndefined();
199+
});
200+
201+
it("carries the filter from the mention into the run", async () => {
202+
const target = { post: vi.fn(async () => ({ id: "m1" })) };
203+
204+
await expect(
205+
handleBlockTestRun(
206+
{ thread: target, message: { text: "test-run block:divider" } },
207+
{ [BLOCK_TESTRUN_ENV_VAR]: "1" },
208+
),
209+
).resolves.toBe(true);
210+
211+
// Header, the one item, and the summary — never the whole catalog.
212+
expect(target.post.mock.calls.length).toBeLessThan(5);
213+
});
214+
});
215+
216+
describe("block test-run summary", () => {
217+
it("names every refusal and every skip", () => {
218+
const text = summaryText([
219+
{ key: "block:section", label: "Section", status: "delivered" },
220+
{
221+
key: "object:workflow",
222+
label: "Workflow",
223+
status: "refused",
224+
error: "invalid_blocks: invalid field at /blocks/1",
225+
expected: "needs a published workflow",
226+
},
227+
{
228+
key: "block:alert",
229+
label: "not postable from a message",
230+
status: "skipped",
231+
reason: "modals only",
232+
},
233+
]);
234+
235+
expect(text).toContain("1 delivered, 1 refused, 1 skipped");
236+
expect(text).toContain("block:section");
237+
expect(text).toContain("invalid field at /blocks/1");
238+
expect(text).toContain("needs a published workflow");
239+
expect(text).toContain("modals only");
240+
});
241+
242+
// A filtered run of a deferred item runs nothing in the main pass, and a
243+
// "0 delivered, 0 refused, 0 skipped" notice reads as a run that did nothing —
244+
// the opposite of the verdict it is about to produce.
245+
it("posts no empty summary for a run of only deferred items", async () => {
246+
const posted: string[] = [];
247+
const target = {
248+
post: vi.fn(async (ui: unknown) => {
249+
if (typeof ui === "string") posted.push(ui);
250+
return { id: "m1" };
251+
}),
252+
};
253+
254+
await runBlockTestRun(target, "rich_text_input");
255+
256+
expect(posted.some((text) => text.includes("0 delivered"))).toBe(false);
257+
expect(posted.some((text) => text.includes("rich_text_input"))).toBe(true);
258+
});
259+
});

0 commit comments

Comments
 (0)