Skip to content

Commit 80a998d

Browse files
tylerslatonclaude
andcommitted
fix(showcase): CR Round 1 — resolve precedence + probe robustness
Bucket (a) findings from CR Round 1, fixed inline: A1. shell-dashboard resolveD5Row precedence Multi-key D5 cells (beautiful-chat → 5 per-pill keys) returned the first non-null row as worst, only upgrading on red. A degraded row encountered after a green row was silently dropped → cell rendered green when it should have been amber. Replaced the "only red wins" check with a numeric rank table (red=3, degraded=2, green=1) so red > degraded > green holds regardless of iteration order. Added 5 multi-key fan-out tests covering: red-after-green, red-before-green, degraded-vs-green order independence, red-beats- degraded, all-green-stays-green. Symbols touched: resolveD5Row (live-status.ts:176), new D5_STATE_RANK constant. Call-site enumeration: resolveD5Row is called only by resolveCell at live-status.ts:371 — same input/ output shape, no caller change needed. A2. fixture _comment lies about aimock arg shape Both gen-ui-agent.json and shared-state-streaming.json's _comment claimed `arguments` MUST be JSON-stringified or aimock silently drops the call. aimock's `normalizeResponse` (verified in node_modules/@copilotkit/aimock/dist/fixture-loader.cjs:14-19) auto-stringifies object-valued arguments at load time, so both shapes work. Updated the comments to reflect reality and stop misleading future fixture authors. A3. e2e-deep per-feature timeout race orphaned runFeature When the synthetic timer won the Promise.race, runFeature was abandoned but never told to tear down. Browser context stayed held until the global timeout eventually fired, while the outer Semaphore.release ran immediately — a NEW feature could acquire the slot while the orphan still held the context, silently exceeding FEATURE_CONCURRENCY's pool budget. Now: a per-feature AbortController forwards the parent abort signal to runFeature; when the timer wins, .abort() fires so runFeature's finally chain tears down its page/context. The setTimeout cleanup is in a try/finally so a thrown rejection (defensive — runFeature's contract says no) doesn't leak the timer. The parent-abort event listener is removed on cleanup to prevent listener accumulation over many feature iterations. Symbols touched: per-feature loop body in executeE2eDeepDriver (e2e-deep.ts:982). runFeature signature unchanged. A5. d5-chat-css user-bubble inner selector substring too loose `[class*="bg-muted"]` matches `bg-muted-foreground` too. Real Tailwind output puts `bg-muted-foreground` on nested children of the user bubble; the probe could read computed styles off the wrong element and silently mis-validate. Switched to `[class~="bg-muted"]` (whole-token match in space-separated class lists), the standard CSS3 way to express "this exact class is present on the element." A7. auth legacy fill/press swallowed errors silently Legacy-shape assertion's catch block dropped fill/press errors so a chat-input cascade mismatch (or disabled textarea after sign-out) produced a generic "error surface did not appear" timeout instead of the real cause. Now captures the error message and appends it to the eventual error string so the failure record names what actually broke. A9. d5-feature-mapping header listed removed `hitl-steps` The header's "destinations" list still showed `hitl-steps : 1 demo` even though my PR's narrative says it was removed in genuine-pass Phase 0 — falsifying a claim my own diff makes. Updated the header to reflect the current REGISTRY_TO_D5 shape: `hitl-text-input` covers the 3 in-chat HITL variants (including the legacy `hitl` alias) and mcp-apps/subagents are split. A6 reclassified to bucket (b) — the 6s waste on chip-driven probes is sub-10% of the new 5-min per-feature timeout and not load-bearing for convergence. Documented in the round summary; can be addressed in a follow-up via a `noSend` ConversationTurn option. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6788f27 commit 80a998d

8 files changed

Lines changed: 187 additions & 37 deletions

File tree

showcase/harness/fixtures/d5/gen-ui-agent.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"_comment": "D5 fixture for /demos/gen-ui-agent. Three pill prompts (product launch / team offsite / competitor research) each produce a `set_steps` tool call with three pill-specific steps, so the per-pill assertion can verify steps differ across pills. The fixture matches on a unique substring per pill. `arguments` MUST be a JSON-stringified string (not a raw object) — aimock follows the OpenAI tool-call wire shape, and a raw object silently drops the call so the frontend's useAgent never sees a state mutation.",
2+
"_comment": "D5 fixture for /demos/gen-ui-agent. Three pill prompts (product launch / team offsite / competitor research) each produce a `set_steps` tool call with three pill-specific steps, so the per-pill assertion can verify steps differ across pills. The fixture matches on a unique substring per pill. `arguments` is shown JSON-stringified here for parity with the OpenAI wire shape; aimock's `normalizeResponse` (see node_modules/@copilotkit/aimock/dist/fixture-loader) auto-stringifies object-valued arguments at load time, so raw-object form would also work — pick one and stay consistent within a file.",
33
"fixtures": [
44
{
55
"match": { "userMessage": "Plan a product launch" },

showcase/harness/fixtures/d5/shared-state-streaming.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"_comment": "D5 fixture for /demos/shared-state-streaming. Three pill prompts produce `write_document` tool calls with non-trivial content payloads (≥ 100 chars) so the document streams into shared state and the DocumentView's char-count assertion can verify a substantive document was written. `arguments` MUST be a JSON-stringified string (not a raw object) — aimock follows the OpenAI tool-call wire shape; a raw object silently drops the call so write_document never executes and the document never streams.",
2+
"_comment": "D5 fixture for /demos/shared-state-streaming. Three pill prompts produce `write_document` tool calls with non-trivial content payloads (≥ 100 chars) so the document streams into shared state and the DocumentView's char-count assertion can verify a substantive document was written. `arguments` is shown JSON-stringified here for parity with the OpenAI wire shape; aimock's `normalizeResponse` (see node_modules/@copilotkit/aimock/dist/fixture-loader) auto-stringifies object-valued arguments at load time, so raw-object form would also work.",
33
"fixtures": [
44
{
55
"match": { "userMessage": "poem about autumn leaves" },

showcase/harness/src/probes/drivers/e2e-deep.ts

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -986,31 +986,67 @@ export function createE2eDeepDriver(
986986
// global hit). The synthetic timeout result is shaped like
987987
// a normal runFeature failure so the rest of the loop is
988988
// unchanged.
989+
//
990+
// Cancellation: per-feature abort controller's signal is
991+
// forwarded to runFeature. When the timer wins the race we
992+
// call `.abort()` so runFeature's in-flight Playwright ops
993+
// (and the page/context they hold) get torn down via the
994+
// existing finally chain — instead of orphaning the browser
995+
// context until the global timeout eventually fires. This
996+
// matters because Semaphore.release() runs immediately
997+
// after the race resolves, and a new feature acquiring the
998+
// slot while an orphan still holds a context can silently
999+
// exceed FEATURE_CONCURRENCY's pool budget.
1000+
//
1001+
// Timer cleanup is in a try/finally so a thrown rejection
1002+
// from runFeature (defensive — the contract says it
1003+
// doesn't throw) doesn't leak the unref'd setTimeout.
1004+
const featureAbort = new AbortController();
1005+
// Propagate the parent abort (global timeout / external
1006+
// abort) into the per-feature controller so runFeature
1007+
// sees both signals through one input.
1008+
const onParentAbort = (): void => featureAbort.abort();
1009+
if (abort.signal.aborted) featureAbort.abort();
1010+
else
1011+
abort.signal.addEventListener("abort", onParentAbort, {
1012+
once: true,
1013+
});
1014+
9891015
let featureTimer: ReturnType<typeof setTimeout> | undefined;
990-
const featureResult = await Promise.race([
991-
runFeature({
992-
browser: browserRef,
993-
url,
994-
pageTimeoutMs,
995-
script,
996-
buildCtx: {
997-
integrationSlug: slug,
998-
featureType: ft,
999-
baseUrl: backendUrl,
1000-
},
1001-
abortSignal: abort.signal,
1002-
}),
1003-
new Promise<Awaited<ReturnType<typeof runFeature>>>((resolve) => {
1004-
featureTimer = setTimeout(() => {
1005-
resolve({
1006-
ok: false,
1007-
errorClass: "feature-timeout",
1008-
errorDesc: `feature exceeded ${featureTimeoutMs}ms wall-clock`,
1009-
});
1010-
}, featureTimeoutMs);
1011-
}),
1012-
]);
1013-
if (featureTimer) clearTimeout(featureTimer);
1016+
let featureResult: Awaited<ReturnType<typeof runFeature>>;
1017+
try {
1018+
featureResult = await Promise.race([
1019+
runFeature({
1020+
browser: browserRef,
1021+
url,
1022+
pageTimeoutMs,
1023+
script,
1024+
buildCtx: {
1025+
integrationSlug: slug,
1026+
featureType: ft,
1027+
baseUrl: backendUrl,
1028+
},
1029+
abortSignal: featureAbort.signal,
1030+
}),
1031+
new Promise<Awaited<ReturnType<typeof runFeature>>>(
1032+
(resolve) => {
1033+
featureTimer = setTimeout(() => {
1034+
// Tell the in-flight runFeature to tear down so
1035+
// its browser context releases promptly.
1036+
featureAbort.abort();
1037+
resolve({
1038+
ok: false,
1039+
errorClass: "feature-timeout",
1040+
errorDesc: `feature exceeded ${featureTimeoutMs}ms wall-clock`,
1041+
});
1042+
}, featureTimeoutMs);
1043+
},
1044+
),
1045+
]);
1046+
} finally {
1047+
if (featureTimer) clearTimeout(featureTimer);
1048+
abort.signal.removeEventListener("abort", onParentAbort);
1049+
}
10141050

10151051
if (featureResult.ok) {
10161052
await sideEmit(ctx, {

showcase/harness/src/probes/helpers/d5-feature-mapping.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,19 @@ import type { D5FeatureType } from "./d5-registry.js";
4545
* (headless chat surfaces — simple is text-only post-refactor;
4646
* complete still drives the full gen-UI surface)
4747
* - `gen-ui-custom` : 1 demo
48-
* - `hitl-text-input` : 2 demos (in-chat HITL variants using useHumanInTheLoop)
49-
* - `hitl-steps` : 1 demo (step-selection confirmation)
48+
* - `hitl-text-input` : 3 demos (the two in-chat HITL variants
49+
* using useHumanInTheLoop, plus the legacy `hitl` alias which was
50+
* repointed here after the standalone `hitl-steps` D5 script was
51+
* removed in genuine-pass Phase 0)
5052
* - `hitl-approve-deny` : 1 demo (modal/in-app approval)
5153
* - `shared-state-read|write`: 1 demo, 2 D5 types (one-to-many)
52-
* - `mcp-apps` : 1 demo
53-
* - `subagents` : 1 demo
54+
* - `mcp-apps` : 1 demo (own probe; was previously
55+
* bundled with subagents, split in Phase 2A)
56+
* - `subagents` : 1 demo (split alongside mcp-apps)
57+
* - other registry families (auth, multimodal, voice, frontend-tools,
58+
* reasoning-display, gen-ui-*, byoc, beautiful-chat-*, …) follow
59+
* the same `<registry-id>: [<d5-feature-types>]` shape and live
60+
* directly in REGISTRY_TO_D5 below.
5461
*/
5562
/**
5663
* Exported for the dashboard drift test (`d5-mapping-drift.test.ts`),

showcase/harness/src/probes/scripts/d5-auth.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,14 @@ export function buildAuthAssertion(
214214
);
215215
}
216216
await new Promise<void>((r) => setTimeout(r, 500));
217+
// Try to push a probe message through; if fill or press fails
218+
// (textarea not found, selector cascade mismatch, disabled control),
219+
// capture the error so the eventual "no error surface" failure
220+
// names the real cause instead of a generic timeout. The error
221+
// surface MAY already be visible from the sign-out click alone —
222+
// the polling loop below handles that case — so we don't fail
223+
// hard on fill/press; we just preserve the diagnostic.
224+
let probeSendError: string | null = null;
217225
try {
218226
await page.fill(
219227
'[data-testid="copilot-chat-textarea"]',
@@ -223,16 +231,19 @@ export function buildAuthAssertion(
223231
await page.press('[data-testid="copilot-chat-textarea"]', "Enter", {
224232
timeout: 2_000,
225233
});
226-
} catch {
227-
// Fall through — error surface may already be visible.
234+
} catch (err) {
235+
probeSendError = err instanceof Error ? err.message : String(err);
228236
}
229237
const errorDeadline = Date.now() + remaining;
230238
while (Date.now() < errorDeadline) {
231239
if (await probeErrorSurfaceVisible(page)) return;
232240
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
233241
}
242+
const sendNote = probeSendError
243+
? ` — probe send failed: ${probeSendError.slice(0, 140)}`
244+
: "";
234245
throw new Error(
235-
`auth: legacy shape — banner flipped to unauthenticated but neither ${ERROR_BANNER_SELECTOR} nor ${ERROR_BOUNDARY_SELECTOR} appeared within ${remaining}ms after probe send — auth gate may have regressed`,
246+
`auth: legacy shape — banner flipped to unauthenticated but neither ${ERROR_BANNER_SELECTOR} nor ${ERROR_BOUNDARY_SELECTOR} appeared within ${remaining}ms after probe send — auth gate may have regressed${sendNote}`,
236247
);
237248
};
238249
}

showcase/harness/src/probes/scripts/d5-chat-css.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,14 @@ export const ASSISTANT_BUBBLE_SELECTOR =
4242
".copilotKitMessage.copilotKitAssistantMessage";
4343
/** The user bubble's inner v2 element — paints the halcyon-paper-elevated
4444
* background plus the ember left border. The outer message wrapper is
45-
* transparent in the new theme; signals all live on this inner node. */
46-
export const USER_BUBBLE_INNER_SELECTOR = `${USER_BUBBLE_SELECTOR} [class*="bg-muted"]`;
45+
* transparent in the new theme; signals all live on this inner node.
46+
*
47+
* Uses `[class~="bg-muted"]` (whole-token match in space-separated
48+
* class lists) rather than `[class*="bg-muted"]` (substring match):
49+
* the latter accidentally also matches `bg-muted-foreground`, which
50+
* appears on nested children in real Tailwind output and would cause
51+
* the probe to read computed styles off the wrong element. */
52+
export const USER_BUBBLE_INNER_SELECTOR = `${USER_BUBBLE_SELECTOR} [class~="bg-muted"]`;
4753

4854
// ── HALCYON theme anchors (langgraph-python) ──────────────────────────
4955
/** halcyon-ember rgb anchor (`#c44a1f`) on the user-bubble inner border-left. */
@@ -122,7 +128,7 @@ export async function probeChatCss(page: Page): Promise<ChatCssProbeResult> {
122128
// includes `bg-muted` on the bubble div.
123129
const userInner = (
124130
userOuter as { querySelector?(sel: string): unknown } | null
125-
)?.querySelector?.('[class*="bg-muted"]');
131+
)?.querySelector?.('[class~="bg-muted"]');
126132
const assistantEl = win.document.querySelector(
127133
".copilotKitMessage.copilotKitAssistantMessage",
128134
);

showcase/shell-dashboard/src/lib/live-status.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,71 @@ describe("resolveCell — post-Phase 3 (rollup uses health + e2e only)", () => {
364364
expect(c.d5.row).toBeNull();
365365
expect(c.d6.row).toBeNull();
366366
});
367+
368+
// ── multi-key D5 fan-out (e.g. beautiful-chat → 5 per-pill keys) ──
369+
// The CATALOG_TO_D5_KEY mapping fans some catalog feature IDs to
370+
// multiple D5 keys (beautiful-chat → 5 per-pill literals). The
371+
// rolled-up cell must reflect the WORST-state row in the family —
372+
// red > degraded > green — so a single amber pill turns the badge
373+
// amber instead of staying green behind a co-iterated green sibling.
374+
it("d5 multi-key fan-out: red beats green when red comes after green", () => {
375+
const live = mapOf([
376+
row("d5:agno/beautiful-chat-toggle-theme", "d5", "green"),
377+
row("d5:agno/beautiful-chat-pie-chart", "d5", "red"),
378+
]);
379+
const c = resolveCell(live, "agno", "beautiful-chat");
380+
expect(c.d5.tone).toBe("red");
381+
expect(c.d5.label).toBe("✗");
382+
});
383+
384+
it("d5 multi-key fan-out: red beats green when red comes BEFORE green", () => {
385+
const live = mapOf([
386+
row("d5:agno/beautiful-chat-toggle-theme", "d5", "red"),
387+
row("d5:agno/beautiful-chat-pie-chart", "d5", "green"),
388+
]);
389+
const c = resolveCell(live, "agno", "beautiful-chat");
390+
expect(c.d5.tone).toBe("red");
391+
});
392+
393+
it("d5 multi-key fan-out: degraded beats green regardless of iteration order", () => {
394+
// Pre-fix regression: only `red` could replace `worst`, so a degraded
395+
// row encountered after a green row was silently dropped and the
396+
// badge stayed green. With the fix, degraded > green wins.
397+
const liveGreenFirst = mapOf([
398+
row("d5:agno/beautiful-chat-toggle-theme", "d5", "green"),
399+
row("d5:agno/beautiful-chat-pie-chart", "d5", "degraded"),
400+
]);
401+
expect(resolveCell(liveGreenFirst, "agno", "beautiful-chat").d5.tone).toBe(
402+
"amber",
403+
);
404+
405+
const liveDegradedFirst = mapOf([
406+
row("d5:agno/beautiful-chat-toggle-theme", "d5", "degraded"),
407+
row("d5:agno/beautiful-chat-pie-chart", "d5", "green"),
408+
]);
409+
expect(
410+
resolveCell(liveDegradedFirst, "agno", "beautiful-chat").d5.tone,
411+
).toBe("amber");
412+
});
413+
414+
it("d5 multi-key fan-out: red beats degraded", () => {
415+
const live = mapOf([
416+
row("d5:agno/beautiful-chat-toggle-theme", "d5", "degraded"),
417+
row("d5:agno/beautiful-chat-pie-chart", "d5", "red"),
418+
]);
419+
expect(resolveCell(live, "agno", "beautiful-chat").d5.tone).toBe("red");
420+
});
421+
422+
it("d5 multi-key fan-out: all green stays green", () => {
423+
const live = mapOf([
424+
row("d5:agno/beautiful-chat-toggle-theme", "d5", "green"),
425+
row("d5:agno/beautiful-chat-pie-chart", "d5", "green"),
426+
row("d5:agno/beautiful-chat-bar-chart", "d5", "green"),
427+
row("d5:agno/beautiful-chat-search-flights", "d5", "green"),
428+
row("d5:agno/beautiful-chat-schedule-meeting", "d5", "green"),
429+
]);
430+
expect(resolveCell(live, "agno", "beautiful-chat").d5.tone).toBe("green");
431+
});
367432
});
368433

369434
describe("formatTooltip behaviour (via resolveCell)", () => {

showcase/shell-dashboard/src/lib/live-status.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,29 @@ export const CATALOG_TO_D5_KEY: Readonly<Record<string, readonly string[]>> = {
173173
voice: ["voice"],
174174
};
175175

176+
/**
177+
* Resolve the rolled-up D5 row for `(slug, featureId)`.
178+
*
179+
* Precedence across the multi-key set (red > degraded > green) — the
180+
* cell's badge tone reflects the worst-state row in the family. A
181+
* naive "first non-null wins" or "only red wins" implementation
182+
* silently masks degraded sub-rows behind green ones; for a cell like
183+
* `beautiful-chat` which fans out to 5 per-pill keys, that means an
184+
* amber sub-row would render the cell green and operators would never
185+
* see the partial regression.
186+
*
187+
* Missing rows are treated as not-yet-emitted and are NOT a signal of
188+
* health — but they also can't be "worst" because we can't compare
189+
* them to anything. The caller (`resolveCell`) renders gray when no
190+
* row is returned, which surfaces "no data yet" distinctly from
191+
* green.
192+
*/
193+
const D5_STATE_RANK: Readonly<Record<State, number>> = {
194+
red: 3,
195+
degraded: 2,
196+
green: 1,
197+
};
198+
176199
function resolveD5Row(
177200
live: LiveStatusMap,
178201
slug: string,
@@ -186,7 +209,9 @@ function resolveD5Row(
186209
for (const d5Key of d5Keys) {
187210
const row = live.get(keyFor("d5", slug, d5Key)) ?? null;
188211
if (!row) continue;
189-
if (!worst || row.state === "red") worst = row;
212+
if (!worst || D5_STATE_RANK[row.state] > D5_STATE_RANK[worst.state]) {
213+
worst = row;
214+
}
190215
}
191216
return worst;
192217
}

0 commit comments

Comments
 (0)