Skip to content

Commit 12c230a

Browse files
authored
fix(showcase): D5 probe hydration retry + assertion relaxation (CopilotKit#4393)
## Summary - Add send-verification retry loop to conversation runner — after fill+press Enter, poll for user message in DOM; retry up to 3× if hydration race swallowed the keypress (~60% of D5 failures) - Relax D5 assertions for cross-integration compatibility: remove "Tokyo" city label check from tool-rendering (card structure is the D5 signal), reduce subagents fragments from 5→2 (~20% of failures) - Remove false-pass `[data-testid="copilot-assistant-message"]` from tool card selector cascade (would match plain text replies, defeating card-structure verification) - Document 3 per-integration D5 bugs on Notion (langgraph-python shared-state, agno hitl, ms-agent-dotnet hitl) ## Why D5 (e2e-deep) probes were at 18/35 pass rate. Root cause analysis identified three failure categories: React hydration race in conversation runner (Category A ~60%), overly-strict assertions that fail on non-LGP integrations (Category B ~20%), and per-integration bugs (Category C ~10%). Full analysis: https://www.notion.so/35035b13359e81ce910bf5ac12f2d2ba ## Test plan - [x] 52 tests pass across conversation-runner, d5-tool-rendering, d5-mcp-subagents - [x] 6 new tests: fillAndVerifySend (first-try success, retry success, all-retries-exhausted), readUserMessageCount (empty, canonical, error) - [x] Existing test updates for relaxed assertions - [x] 7-agent CR R1 (1 finding: false-pass selector) → fixed → R2 (0 findings, converged) - [ ] Deploy and monitor next e2e-deep probe run for pass rate improvement
2 parents 15a7304 + 3bfe757 commit 12c230a

6 files changed

Lines changed: 365 additions & 58 deletions

File tree

showcase/harness/src/probes/helpers/conversation-runner.test.ts

Lines changed: 211 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, it, expect } from "vitest";
2-
import { runConversation } from "./conversation-runner.js";
2+
import {
3+
runConversation,
4+
fillAndVerifySend,
5+
readUserMessageCount,
6+
} from "./conversation-runner.js";
37
import type { ConversationTurn, Page } from "./conversation-runner.js";
48

59
/**
@@ -31,10 +35,39 @@ interface PageScript {
3135
throwOnPress?: Error;
3236
// Recorded inputs the runner sent; tests assert on these.
3337
recorded?: { fills: string[]; presses: string[] };
38+
// Scripted user-message counts for the send-verification retry loop.
39+
// When provided, `page.evaluate` checks whether the evaluate function
40+
// body references user-message selectors and returns from this queue
41+
// instead of the main `evaluateValues` queue.
42+
userMessageValues?: number[];
43+
}
44+
45+
/**
46+
* Wrap an evaluate function so that user-message reads (from
47+
* `readUserMessageCount`) return a monotonically increasing count —
48+
* simulating that the user message appeared on first try after each
49+
* fill+press. This prevents `fillAndVerifySend` from burning 2s×3
50+
* retries in tests that don't care about the send-verification loop.
51+
*/
52+
function wrapEvaluateForUserMessages(
53+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
54+
inner: (...args: any[]) => Promise<any>,
55+
): Page["evaluate"] {
56+
let userCalls = 0;
57+
return (async <R>(fn: () => R): Promise<R> => {
58+
if (fn.toString().includes("copilot-user-message")) {
59+
return userCalls++ as never;
60+
}
61+
return inner(fn) as Promise<R>;
62+
}) as Page["evaluate"];
3463
}
3564

3665
function makePage(script: PageScript = {}): Page {
3766
const queue = [...(script.evaluateValues ?? [])];
67+
const userQueue = [...(script.userMessageValues ?? [])];
68+
// Auto-succeed counter: first user-message read = 0 (baseline),
69+
// subsequent reads = 1 (growth detected → verify loop succeeds).
70+
let autoUserCalls = 0;
3871
return {
3972
async waitForSelector() {
4073
// No-op — the runner uses this only to confirm the chat input exists.
@@ -49,6 +82,25 @@ function makePage(script: PageScript = {}): Page {
4982
},
5083
async evaluate(fn) {
5184
if (script.evaluate) return script.evaluate(fn) as never;
85+
86+
// Detect whether the evaluate call is reading user messages or
87+
// assistant messages by inspecting the function body. The
88+
// readUserMessageCount function references "copilot-user-message"
89+
// while readMessageCount references "copilot-assistant-message".
90+
const fnBody = fn.toString();
91+
if (fnBody.includes("copilot-user-message")) {
92+
if (userQueue.length > 0) {
93+
if (userQueue.length === 1) return userQueue[0]! as never;
94+
return userQueue.shift()! as never;
95+
}
96+
// No explicit user-message script — auto-succeed so the
97+
// verify loop doesn't burn time in tests that only care about
98+
// assistant-message settling. Returns a monotonically
99+
// increasing count so every fillAndVerifySend call sees
100+
// growth past its baseline on the first poll.
101+
return autoUserCalls++ as never;
102+
}
103+
52104
// Drain one value per call. Once exhausted, freeze on the last
53105
// value so any post-script poll sees the steady-state count
54106
// (matches a real assistant message that has finished streaming).
@@ -238,12 +290,12 @@ describe("runConversation", () => {
238290
},
239291
async fill() {},
240292
async press() {},
241-
async evaluate() {
293+
evaluate: wrapEvaluateForUserMessages(async () => {
242294
// First read is the baseline (= 0); subsequent reads return 1
243295
// and stay there → growth past baseline + stable → settled.
244296
evalCalls++;
245297
return (evalCalls === 1 ? 0 : 1) as never;
246-
},
298+
}),
247299
};
248300

249301
const result = await runConversation(page, [{ input: "hi" }], {
@@ -300,10 +352,10 @@ describe("runConversation", () => {
300352
}
301353
},
302354
async press() {},
303-
async evaluate() {
355+
evaluate: wrapEvaluateForUserMessages(async () => {
304356
evalCalls++;
305357
return (evalCalls === 1 ? 0 : 1) as never;
306-
},
358+
}),
307359
};
308360

309361
const result = await runConversation(page, [{ input: "hello" }], {
@@ -326,10 +378,10 @@ describe("runConversation", () => {
326378
},
327379
async fill() {},
328380
async press() {},
329-
async evaluate() {
381+
evaluate: wrapEvaluateForUserMessages(async () => {
330382
evalCalls++;
331383
return (evalCalls === 1 ? 0 : 1) as never;
332-
},
384+
}),
333385
};
334386

335387
const result = await runConversation(page, [{ input: "hi" }], {
@@ -397,14 +449,20 @@ describe("runConversation", () => {
397449
// on subsequent reads. The runner's readMessageCount catch returns 0
398450
// on error so the baseline becomes 0 and the turn still settles when
399451
// the count grows.
400-
let calls = 0;
452+
let assistantCalls = 0;
453+
let userCalls = 0;
401454
const page: Page = {
402455
async waitForSelector() {},
403456
async fill() {},
404457
async press() {},
405-
async evaluate() {
406-
calls++;
407-
if (calls === 1) throw new Error("evaluate boom");
458+
async evaluate(fn) {
459+
// User-message reads return a monotonically increasing count
460+
// so fillAndVerifySend sees growth and doesn't retry.
461+
if (fn.toString().includes("copilot-user-message")) {
462+
return userCalls++ as never;
463+
}
464+
assistantCalls++;
465+
if (assistantCalls === 1) throw new Error("evaluate boom");
408466
return 1 as never;
409467
},
410468
};
@@ -452,11 +510,17 @@ describe("runConversation", () => {
452510
},
453511
};
454512

513+
let userMsgCalls1 = 0;
455514
const page: Page = {
456515
async waitForSelector() {},
457516
async fill() {},
458517
async press() {},
459518
async evaluate(fn) {
519+
// User-message reads bypass the fake document entirely — we
520+
// only care about assistant-message selector queries here.
521+
if (fn.toString().includes("copilot-user-message")) {
522+
return userMsgCalls1++ as never;
523+
}
460524
// Patch globalThis.document with our fake for the duration
461525
// of the evaluate call. The selector-fn closes over
462526
// globalThis at runtime, mirroring the browser-side execution.
@@ -510,11 +574,16 @@ describe("runConversation", () => {
510574
},
511575
};
512576

577+
let userMsgCalls2 = 0;
513578
const page: Page = {
514579
async waitForSelector() {},
515580
async fill() {},
516581
async press() {},
517582
async evaluate(fn) {
583+
// User-message reads bypass the fake document entirely.
584+
if (fn.toString().includes("copilot-user-message")) {
585+
return userMsgCalls2++ as never;
586+
}
518587
const originalDoc = (globalThis as { document?: unknown }).document;
519588
(globalThis as { document?: unknown }).document = fakeDocument;
520589
try {
@@ -564,3 +633,134 @@ describe("runConversation", () => {
564633
expect(result.error).toContain("non-error string boom");
565634
});
566635
});
636+
637+
describe("fillAndVerifySend", () => {
638+
it("succeeds on first attempt when user message appears immediately", async () => {
639+
const recorded = { fills: [] as string[], presses: [] as string[] };
640+
// User message count: baseline=0, then 1 after Enter → success on first attempt.
641+
const page = makePage({
642+
recorded,
643+
userMessageValues: [0, 1],
644+
});
645+
646+
await fillAndVerifySend(page, "textarea", "hello world");
647+
648+
// fill+press called exactly once — no retry needed.
649+
expect(recorded.fills).toEqual(["hello world"]);
650+
expect(recorded.presses).toEqual(["Enter"]);
651+
});
652+
653+
it("retries when first attempt fails (no user message) and succeeds on second", async () => {
654+
const recorded = { fills: [] as string[], presses: [] as string[] };
655+
// Attempt 1: baseline=0, single poll returns 0 → timeout, retry.
656+
// Attempt 2: single poll returns 1 → success.
657+
// With short delays (initialDelayMs=10, timeoutMs=50) each attempt
658+
// fits exactly 1 poll (POLL_INTERVAL_MS=100 > remaining 40ms window).
659+
const userValues: number[] = [
660+
0, // baseline read
661+
0, // attempt 1: single poll → no growth → retry
662+
1, // attempt 2: single poll → growth → success
663+
];
664+
const page = makePage({
665+
recorded,
666+
userMessageValues: userValues,
667+
});
668+
669+
await fillAndVerifySend(page, "textarea", "retry me", {
670+
initialDelayMs: 10,
671+
timeoutMs: 50,
672+
});
673+
674+
// fill+press called twice — first attempt failed, second succeeded.
675+
expect(recorded.fills).toEqual(["retry me", "retry me"]);
676+
expect(recorded.presses).toEqual(["Enter", "Enter"]);
677+
});
678+
679+
it("falls through after all 3 retries fail without throwing", async () => {
680+
const recorded = { fills: [] as string[], presses: [] as string[] };
681+
// All 3 attempts see user message count stuck at 0. The function
682+
// should return silently (not throw) so the downstream timeout
683+
// produces a clear failure message.
684+
const page = makePage({
685+
recorded,
686+
userMessageValues: [0], // stays at 0 forever (single-value freeze)
687+
});
688+
689+
// Should NOT throw — just returns after exhausting retries.
690+
// Use short delays so the test doesn't exceed the 5s timeout.
691+
await fillAndVerifySend(page, "textarea", "doomed", {
692+
initialDelayMs: 10,
693+
timeoutMs: 50,
694+
});
695+
696+
// fill+press called 3 times (max attempts).
697+
expect(recorded.fills).toEqual(["doomed", "doomed", "doomed"]);
698+
expect(recorded.presses).toEqual(["Enter", "Enter", "Enter"]);
699+
});
700+
});
701+
702+
describe("readUserMessageCount", () => {
703+
it("returns 0 when no user messages exist", async () => {
704+
const fakeDocument = {
705+
querySelectorAll: (_sel: string): { length: number } => ({ length: 0 }),
706+
};
707+
const page: Page = {
708+
async waitForSelector() {},
709+
async fill() {},
710+
async press() {},
711+
async evaluate(fn) {
712+
const originalDoc = (globalThis as { document?: unknown }).document;
713+
(globalThis as { document?: unknown }).document = fakeDocument;
714+
try {
715+
return fn() as never;
716+
} finally {
717+
(globalThis as { document?: unknown }).document = originalDoc;
718+
}
719+
},
720+
};
721+
expect(await readUserMessageCount(page)).toBe(0);
722+
});
723+
724+
it("prefers canonical testid when present", async () => {
725+
const queriedSelectors: string[] = [];
726+
const fakeDocument = {
727+
querySelectorAll: (sel: string): { length: number } => {
728+
queriedSelectors.push(sel);
729+
if (sel === '[data-testid="copilot-user-message"]') {
730+
return { length: 3 };
731+
}
732+
// Should never reach these — canonical short-circuits.
733+
return { length: 999 };
734+
},
735+
};
736+
const page: Page = {
737+
async waitForSelector() {},
738+
async fill() {},
739+
async press() {},
740+
async evaluate(fn) {
741+
const originalDoc = (globalThis as { document?: unknown }).document;
742+
(globalThis as { document?: unknown }).document = fakeDocument;
743+
try {
744+
return fn() as never;
745+
} finally {
746+
(globalThis as { document?: unknown }).document = originalDoc;
747+
}
748+
},
749+
};
750+
expect(await readUserMessageCount(page)).toBe(3);
751+
// Should NOT have fallen through to the other selectors.
752+
expect(queriedSelectors).toEqual(['[data-testid="copilot-user-message"]']);
753+
});
754+
755+
it("returns 0 on evaluate error", async () => {
756+
const page: Page = {
757+
async waitForSelector() {},
758+
async fill() {},
759+
async press() {},
760+
async evaluate() {
761+
throw new Error("page crashed");
762+
},
763+
};
764+
expect(await readUserMessageCount(page)).toBe(0);
765+
});
766+
});

0 commit comments

Comments
 (0)