-
Notifications
You must be signed in to change notification settings - Fork 87
Expand file tree
/
Copy pathcases.ts
More file actions
508 lines (496 loc) · 19.4 KB
/
Copy pathcases.ts
File metadata and controls
508 lines (496 loc) · 19.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
/**
* Catalog of real end-to-end test cases the harness sends as live Slack
* messages and samples back via the Slack API while the bot streams.
*
* Each case is technical-axis-flavoured (not product-flavoured) — the
* prompt is just whatever phrasing reliably triggers the dimension we
* want to measure.
*
* Fields:
* name human-readable label
* prompt user's message text — what gets sent in #ag-ui-bot-test
* sampleIntervalMs how often to poll the bot's reply during streaming
* maxWaitMs give up sampling after this long
* screenshots sample times (ms after send) to take mid-stream
* screenshots in the Slack web UI
* expectations checks to run on the final text
*
* Add cases liberally. The catalog itself is the test surface.
*/
export interface E2ECase {
name: string;
prompt: string;
sampleIntervalMs?: number;
maxWaitMs?: number;
screenshots?: number[];
/**
* Optional follow-up turn that gets sent INTO the thread that this case's
* first prompt creates. Used to test thread-continuation without
* re-mentioning the bot. The follow-up has its own prompt + expectations
* and reuses the same sampleIntervalMs / maxWaitMs.
*/
followUp?: {
prompt: string;
expectations?: E2ECase["expectations"];
};
/**
* Mid-stream interrupt: send a second user message into the SAME thread
* `afterMs` after kicking off the first prompt. Used to verify that the
* in-flight bot reply is aborted, marked as interrupted in Slack, and
* the new turn produces a fresh reply.
*/
interrupt?: {
afterMs: number;
prompt: string;
/** Expectations applied to the interrupted FIRST reply. */
firstExpectations?: E2ECase["expectations"];
/** Expectations applied to the new (second) reply. */
expectations?: E2ECase["expectations"];
};
expectations?: {
/** Bot's final response must contain these substrings (case-insensitive). */
finalContains?: string[];
/** Bot's final response must NOT contain these. */
finalNotContains?: string[];
/** Final mrkdwn must be balanced (no dangling brackets). */
balancedBrackets?: boolean;
/** Minimum reply length in chars (catches truncation regressions). */
minLength?: number;
/**
* Verifies the final text contains a monospace table whose rows all
* have the same line length — i.e. columns are aligned, not pipe-soup.
*/
monospaceAlignedTable?: boolean;
/**
* Counts how many distinct Slack messages this case produced (after
* the bot's parent message). Used to verify chunking-keeps-whole-block
* behaviour: a long fenced block should land in one message, not split.
*/
expectedChunkCount?: number;
/**
* Custom predicate run against the *full* set of bot replies in the
* thread (NOT just the first one). Useful for asserting properties
* across chunked output, e.g. "the fence opener appears at the start
* of exactly one message" or "no message text contains a dangling ```".
*/
/**
* Custom predicate. `replies` is the bot's per-message text array;
* `raw` is the full Slack message objects (with `blocks`, `ts`, etc.)
* for cases that need to inspect Block Kit structure.
*/
perReplyChecks?: (
replies: string[],
raw: Array<Record<string, any>>,
) => string[];
};
}
function confirmWriteCard(
raw: Array<Record<string, any>>,
): {
buttons: Array<Record<string, any>>;
errors: string[];
} {
const blocks = raw.flatMap((message) => [
...((message["blocks"] as Array<Record<string, any>> | undefined) ?? []),
...(
(message["attachments"] as
| Array<{ blocks?: Array<Record<string, any>> }>
| undefined) ?? []
).flatMap((attachment) => attachment.blocks ?? []),
]);
const header = blocks.find((block) => block["type"] === "header");
const headerText = String(
(header?.["text"] as { text?: string } | undefined)?.text ?? "",
);
const buttons = blocks
.filter((block) => block["type"] === "actions")
.flatMap(
(block) =>
(block["elements"] as Array<Record<string, any>> | undefined) ?? [],
)
.filter((element) => element["type"] === "button");
const buttonLabels = buttons.map((button) =>
String(
(button["text"] as { text?: string } | undefined)?.text ?? "",
),
);
const errors: string[] = [];
if (!headerText.trim()) {
errors.push("confirm_write card has no Block Kit header");
}
for (const expected of ["Create", "Cancel"]) {
if (!buttonLabels.includes(expected)) {
errors.push(`confirm_write card has no ${expected} button`);
}
}
return { buttons, errors };
}
export const CASES: E2ECase[] = [
// ── A. Trigger surface ──────────────────────────────────────────────
{
name: "A1 — top-level @mention",
prompt: "<@U0B45V75NNR> say HOTEL in one short sentence",
expectations: { finalContains: ["HOTEL"], minLength: 5 },
},
// /agent slash command requires a real slash-command invocation —
// can't fire it via chat.postMessage. Manual-only for now.
// {
// name: "A10 — /agent slash command",
// prompt: "/agent ping reply with the word PONG",
// expectations: { finalContains: ["PONG"], minLength: 4 },
// },
// ── B. Response length / shape ─────────────────────────────────────
{
name: "B2 — single-token response (was the ECHO/AL bug)",
prompt: "<@U0B45V75NNR> reply with exactly the word ECHO and nothing else",
expectations: {
finalContains: ["ECHO"],
finalNotContains: ["…"],
minLength: 4,
},
},
{
name: "B6 — long response, multi-paragraph",
prompt:
"<@U0B45V75NNR> write 4 paragraphs about the history of the printing press. Take your time. Be detailed.",
sampleIntervalMs: 700,
maxWaitMs: 60_000,
screenshots: [1500, 3500, 7000, 14_000],
expectations: { minLength: 800, balancedBrackets: true },
},
{
name: "B7 — long response (model-bounded; ensures balanced + reasonable length)",
prompt:
"<@U0B45V75NNR> write a thorough 8-paragraph essay about agent protocols. " +
"Each paragraph 4-6 sentences. Be detailed, no apologies.",
sampleIntervalMs: 700,
maxWaitMs: 90_000,
screenshots: [2000, 5000, 12_000, 20_000],
// Lower floor — the chunking code is exercised by the unit tests; here
// we mainly want to see balanced streaming over a long emission.
expectations: { minLength: 1500, balancedBrackets: true },
},
// ── B/markdown — mrkdwn translation ───────────────────────────────
{
name: "B11 — bold/italic markers",
prompt:
"<@U0B45V75NNR> say a sentence with one **bold** word and one *italic* word. Use those exact markdown markers.",
expectations: {
// After mrkdwn translation: bold uses `*`, italic uses `_`
finalContains: ["*", "_"],
finalNotContains: ["**"],
balancedBrackets: true,
},
},
{
name: "B13 — bullet list",
prompt:
"<@U0B45V75NNR> list three programming languages as bullet points using `-` markers.",
expectations: {
finalContains: ["•"], // mrkdwn bullets
balancedBrackets: true,
},
},
{
name: "B16 — fenced code block",
prompt:
"<@U0B45V75NNR> show me a short python snippet for a fibonacci function in a fenced code block",
sampleIntervalMs: 700,
maxWaitMs: 60_000,
screenshots: [1500, 4000, 9000],
expectations: {
// The point: while streaming, the in-flight Slack message has an
// OPEN fence; auto-close keeps the rest of the message renderable.
finalContains: ["```"],
balancedBrackets: true, // dangling ``` would fail this
},
},
{
name: "B17 — table fallback to monospace, COLUMN-ALIGNED",
prompt:
"<@U0B45V75NNR> give me a 3-row markdown table comparing langgraph, ag-ui, and copilotkit (columns: name, role)",
expectations: {
finalContains: ["```", "langgraph", "ag-ui"],
balancedBrackets: true,
monospaceAlignedTable: true,
},
},
{
name: "B-chunk-spill — long fenced block should land WHOLE in one Slack message",
prompt:
"<@U0B45V75NNR> write a self-contained python script that defines 8 small utility functions in ONE fenced code block. Do not split it. Aim for 1500-2500 chars inside the block.",
sampleIntervalMs: 800,
maxWaitMs: 90_000,
expectations: {
finalContains: ["```python", "def "],
balancedBrackets: true,
perReplyChecks: (replies) => {
const errs: string[] = [];
// Among all messages, exactly one should contain ```python (the block).
const withPython = replies.filter((r) =>
r.includes("```python"),
).length;
if (withPython !== 1) {
errs.push(
`expected exactly 1 message containing \`\`\`python; got ${withPython}`,
);
}
// No message should END inside an open fence (autoCloseOpenMarkdown should
// close it, OR the boundary should have moved before the fence opener).
for (let i = 0; i < replies.length; i++) {
const r = replies[i] ?? "";
const fences = (r.match(/```/g) ?? []).length;
if (fences % 2 !== 0) {
errs.push(`message #${i} has unbalanced fences`);
}
}
return errs;
},
},
},
// ── C. Streaming dynamics ─────────────────────────────────────────
{
name: "C-stream-1 — mid-stream bracket polish (open fence)",
prompt:
"<@U0B45V75NNR> describe how python decorators work using ```python ... ``` blocks. Be thorough.",
sampleIntervalMs: 500,
maxWaitMs: 60_000,
screenshots: [1000, 2500, 6000, 12_000],
expectations: { finalContains: ["```python"], balancedBrackets: true },
},
// ── D. Conversation state ─────────────────────────────────────────
{
name: "D-state-1 — thread continuation without re-mention",
prompt: "<@U0B45V75NNR> say the single word ALPHA",
expectations: { finalContains: ["ALPHA"] },
followUp: {
prompt:
"now say the single word BRAVO. no @mention; just reply in this thread.",
expectations: { finalContains: ["BRAVO"] },
},
},
// ── Interrupt: reply mid-stream cancels the in-flight bot reply ──
{
name: "Interrupt — reply mid-stream cancels the in-flight bot reply",
prompt:
"<@U0B45V75NNR> write a really long, slow, 6-paragraph essay about agent protocols. " +
"Take your time. Be exhaustive.",
sampleIntervalMs: 700,
maxWaitMs: 30_000,
interrupt: {
afterMs: 3500,
prompt: "actually never mind. just say PONG and nothing else.",
// The first (interrupted) reply must carry the marker.
firstExpectations: {
finalContains: ["(interrupted)"],
},
// The new reply must contain the new word.
expectations: {
finalContains: ["PONG"],
minLength: 4,
},
},
},
// ── E. Frontend tools & context ───────────────────────────────────
// These exercise the Slack-side primitives (lookup_slack_user tagging,
// the issue_list/page_list components, the confirm_write HITL gate) and
// verify the context entries arrive at the LLM. The Linear/Notion cases
// need real MCP credentials (see .env.example) — without them the agent
// can chat but can't read or write, and those cases no-op.
{
name: "E-tag-1 — agent uses lookup_slack_user to tag Atai in its reply",
prompt:
'<@U0B45V75NNR> call the lookup_slack_user tool with query "atai" to get my ' +
"real Slack user ID, then reply with a friendly greeting that uses the returned " +
'`mention` string verbatim to tag me. Don\'t just write "Atai" as text.',
sampleIntervalMs: 700,
maxWaitMs: 30_000,
expectations: {
// The agent's reply must contain a real <@USERID> mention for Atai.
// U0FF2X1XXXX is just a sanity-check pattern; the real test is the
// perReplyChecks below.
perReplyChecks: (replies) => {
const errs: string[] = [];
const joined = replies.join("\n");
// 1. Some <@U…> mention appears.
if (!/<@U[A-Z0-9]+>/.test(joined)) {
errs.push("no <@USERID> mention found in any bot reply");
}
// 2. No literal "@atai" text without the angle-bracket syntax
// (would mean the agent didn't use the tool).
if (/\B@atai\b/i.test(joined.replace(/<@[UW][A-Z0-9]+>/g, ""))) {
errs.push("bot wrote `@atai` plaintext instead of using <@USERID>");
}
return errs;
},
},
},
{
name: "E-component-1 — agent renders the issue_list component as a Block Kit card",
// Needs Linear MCP creds: the agent pulls issues, then renders them via
// the issue_list component (a separate blocks message in the thread).
prompt:
"<@U0B45V75NNR> show me the open issues in the CPK team this cycle, " +
"and render them with the issue_list component.",
sampleIntervalMs: 700,
maxWaitMs: 45_000,
expectations: {
// conversations.replies includes block-only messages. We assert that
// at least one bot reply carries a section block whose mrkdwn looks
// like an issue row (a CPK-NNN identifier).
perReplyChecks: (_replies, raw) => {
const errs: string[] = [];
const allBlocks = raw.flatMap(
(m) => (m["blocks"] as Array<Record<string, any>> | undefined) ?? [],
);
const hasIssueRow = allBlocks.some(
(b) =>
b["type"] === "section" &&
/CPK-\d+/.test(
String((b["text"] as { text?: string } | undefined)?.text ?? ""),
),
);
if (!hasIssueRow) {
errs.push(
"no issue_list section block with a CPK-NNN identifier was rendered",
);
}
return errs;
},
},
},
{
name: "E-notion-1 — agent searches Notion and renders the page_list component",
// Needs Notion MCP creds.
prompt:
"<@U0B45V75NNR> find any Notion runbooks or postmortems related to a " +
"production outage and render them with the page_list component.",
sampleIntervalMs: 700,
maxWaitMs: 45_000,
expectations: {
perReplyChecks: (_replies, raw) => {
const errs: string[] = [];
const allBlocks = raw.flatMap(
(m) => (m["blocks"] as Array<Record<string, any>> | undefined) ?? [],
);
const hasPageRow = allBlocks.some(
(b) =>
b["type"] === "section" &&
String(
(b["text"] as { text?: string } | undefined)?.text ?? "",
).includes(":page_facing_up:"),
);
if (!hasPageRow) {
errs.push("no page_list section block was rendered");
}
return errs;
},
},
},
{
name: "E-durable-1 — confirm_write buttons serialize action IDs and resume values",
// This live case does not restart the process. It reads the initial picker
// back through conversations.replies and verifies Slack stored durable
// action IDs plus JSON resume values. Handler re-registration with a shared
// store is covered in app/channel.test.ts; this does not claim persistence
// across an operating-system process restart.
prompt:
'<@U0B45V75NNR> file a Linear issue titled "Checkout 500s under load". ' +
"The protected write must ask me to approve it before MCP runs.",
sampleIntervalMs: 700,
maxWaitMs: 20_000,
expectations: {
perReplyChecks: (_replies, raw) => {
const { buttons, errors } = confirmWriteCard(raw);
for (const [label, confirmed] of [
["Create", true],
["Cancel", false],
] as const) {
const btn = buttons.find(
(button) =>
(button["text"] as { text?: string } | undefined)?.text ===
label,
);
if (!btn) continue;
if (
typeof btn["action_id"] !== "string" ||
!btn["action_id"].startsWith("ck:")
) {
errors.push(`${label} button has no durable ck: action_id`);
}
if (!btn.value) {
errors.push(`${label} button has no value field`);
continue;
}
try {
const decoded = JSON.parse(btn.value);
if (decoded?.confirmed !== confirmed) {
errors.push(
`${label} button decoded to unexpected resume value: ${btn.value}`,
);
}
} catch (e) {
errors.push(
`${label} button value isn't valid JSON: ${(e as Error).message}`,
);
}
}
return errors;
},
},
},
{
name: "E-hitl-1 — agent renders the confirm_write HITL Block Kit message",
// Verifies the human-in-the-loop gate renders into the thread. We
// can't simulate the button click via Slack's API, so this case only
// asserts that the initial Block Kit card lands with Create/Cancel.
// Click→resume handling and shared-store action re-registration are covered
// by the Channel unit tests.
prompt:
'<@U0B45V75NNR> file a Linear issue titled "Test from e2e". The ' +
"protected write must ask me to approve it before creating anything.",
sampleIntervalMs: 700,
maxWaitMs: 20_000,
expectations: {
perReplyChecks: (_replies, raw) => confirmWriteCard(raw).errors,
},
},
{
name: "E-context-1 — Slack-usage context is delivered to the LLM",
// Asks the agent to quote from its App Context. If `runAgent({context})` is
// plumbed through and the CopilotKit middleware injects it as a system
// message, the agent will quote a recognisable phrase from
// slackUsageContext. If context isn't being plumbed, the agent has no
// way to know the exact wording.
prompt:
"<@U0B45V75NNR> in one short line: what does your App Context tell you " +
"about how to @-mention people on Slack? Quote the most relevant sentence verbatim.",
sampleIntervalMs: 700,
maxWaitMs: 20_000,
expectations: {
// Any phrase that appears verbatim in slackUsageContext is fine —
// the only way the LLM could quote these strings is from the
// context entries actually being delivered.
perReplyChecks: (replies) => {
const joined = replies.join("\n");
const witnesses = [
"<@USERID>",
"lookup_slack_user",
"<@U05PN5700P9>",
"@-mention",
];
if (witnesses.some((w) => joined.includes(w))) return [];
return [
`final reply quoted no context phrase from ${JSON.stringify(witnesses)}`,
];
},
},
},
// ── F. Loop / echo / subtype filters ──────────────────────────────
{
name: "F-edit — editing a previous message must NOT re-trigger",
prompt: "<@U0B45V75NNR> please respond just ONCE and stop",
// The harness edits the just-sent message and verifies bot does not produce a second reply.
expectations: { minLength: 2 },
},
];