Files
copilotkit__copilotkit/showcase/integrations/ms-agent-python/tests/e2e/tool-rendering-default-catchall.spec.ts
Alem Tuzlak 5cd77233b7 feat(showcase/ms-agent-python): LGP parity sweep — 33/37 cells green
Brings ms-agent-python to one-to-one parity with langgraph-python (the D5
north star). Playwright e2e suite goes from 49/108 (~26%) → 164/178 (~92%),
33 of 37 cells fully green.

Manifest parity:
- Drop 4 MAF-only cells with no LGP analog: agentic-chat-reasoning,
  hitl-in-chat-booking, shared-state-write, reasoning-default-render.
  Reasoning is handled by reasoning-default + reasoning-custom (LGP);
  booking pill folds into hitl-in-chat; shared-state-write was a TODO stub.
- Rename byoc-hashbrown → declarative-hashbrown and byoc-json-render →
  declarative-json-render. Demo dir, API route dir, and frontend agent id
  follow LGP's naming. Python module files retain the legacy `byoc_*`
  prefix and FastAPI paths stay `/byoc-hashbrown` / `/byoc-json-render`
  (matches LGP's "module name retains legacy graph id" convention).
- Port LGP `_shared/`, `_shared/interrupt-fallback-slots.ts`, and
  `demos/layout.tsx` for one-to-one parity.

Cells ported verbatim from LGP (page + spec):
- agentic-chat, auth, beautiful-chat, chat-customization-css, chat-slots,
  declarative-gen-ui, declarative-hashbrown, declarative-json-render,
  frontend-tools, frontend-tools-async, gen-ui-agent, gen-ui-interrupt,
  gen-ui-tool-based, headless-complete, headless-simple, hitl-in-app,
  hitl-in-chat, shared-state-read, shared-state-read-write,
  shared-state-streaming, subagents, tool-rendering, plus all four
  tool-rendering* variants, a2ui-fixed-schema, agent-config, mcp-apps,
  multimodal, open-gen-ui, open-gen-ui-advanced, prebuilt-popup,
  prebuilt-sidebar, readonly-state-agent-context, reasoning-default,
  reasoning-custom, voice.

Backend infrastructure:
- Swap shared `OpenAIChatClient` (Responses API) → `OpenAIChatCompletionClient`
  (ChatCompletions). Root cause of the cross-cell post-tool ChatClientException
  family: Responses API is stateful and only sends NEW items per leg,
  relying on `previous_response_id` for history. aimock has no view of
  that server-side state, so second-leg requests arrived without the
  user message — fixture matchers keyed on `userMessage` couldn't fire
  and the run fell through to real OpenAI. ChatCompletions sends full
  history every leg, matching the LGP wire shape.
- Bump @ag-ui/client ^0.0.43 → ^0.0.53 (matches google-adk/LGP). Fixes
  the REASONING_* Zod discriminator trap on the catch-all agent.
- Regenerate package-lock.json in isolation outside the pnpm monorepo so
  npm-arborist doesn't resolve transitives against pnpm's hoisted
  symlinks (avoid 40+ `../../../node_modules/.pnpm/...` paths in the
  lockfile that break `npm ci` inside Docker).
- Add `yaml` (^2.8.4) for the new `src/app/demos/layout.tsx` that reads
  manifest.yaml for per-cell page titles (LGP parity).

New / re-added MAF agent backends with LGP-equivalent behavior:
- reasoning_agent.py (uses Responses API explicitly — the only chat
  client that emits AG-UI REASONING_MESSAGE_* events; rest of the
  integration stays on ChatCompletions).
- tool_rendering_agent.py (non-reasoning sibling of the existing
  reasoning_chain variant; shares tool surface via direct imports so
  they can never drift apart; routes the three catchall cells to a
  non-reasoning backend so the default renderer spec stops failing on
  leaked reasoning blocks).
- gen_ui_agent.py — `set_steps` tool + `steps` state schema +
  `predict_state_config` mirrors LGP's StateStreamingMiddleware shape.
- shared_state_streaming.py — `write_document` tool with
  `predict_state_config` that streams the `document` arg into
  `state.document` per-token.
- readonly_state_agent_context.py — minimal agent that consumes
  frontend-provided `useAgentContext` entries; no tools.
- headless_complete_agent.py — three deterministic tools (`get_weather`,
  `get_stock_price`, `get_revenue_chart`) mounted at /headless-complete
  on the mcp-apps runtime (was routing to catch-all sales agent, which
  returned seeded-random weather instead of the deterministic 68°F the
  test asserts on).

Wiring:
- copilotkit/route.ts: register the new agents, drop the stale
  shared-state-write entry, route all three tool-rendering variants to
  the non-reasoning backend (the reasoning-chain cell keeps its own
  dedicated path), register reasoning-default + reasoning-custom on
  /reasoning, register gen-ui-agent on /gen-ui-agent,
  shared-state-streaming on /shared-state-streaming,
  readonly-state-agent-context on its dedicated path.
- copilotkit-mcp-apps/route.ts: register headless-complete agent (was
  missing — the strict useAgent runtime sync in the newer
  @copilotkit/react-core surfaced the gap).
- copilotkit-declarative-hashbrown/route.ts + copilotkit-declarative-json-render/route.ts:
  new dedicated runtimes; agent IDs and runtime URLs follow LGP.
- copilotkit-declarative-gen-ui/route.ts: drop non-LGP `openGenerativeUI:
  false` for parity.

A2UI tool rename — `render_a2ui` → `_design_a2ui_surface`:
- Ported LGP's `tools/generate_a2ui.py` (LGP renamed the secondary-LLM
  tool to `_design_a2ui_surface` to avoid the A2UI middleware's bypass;
  shared d5-all.json fixtures key the response on this name).
- Renamed every `render_a2ui` occurrence in src/agents/{a2ui_dynamic,
  agent,beautiful_chat}.py and `tools/__init__.py`.
- Updated 4 declarative-gen-ui aimock fixtures to pass `context` arg in
  the first-leg `generate_a2ui` tool call (agent_framework doesn't
  auto-inject AgentSession into our @tool function so `session=None` and
  the secondary-LLM `user_content` was defaulting to a catch-all string
  containing "KPI dashboard" — every pill matched the KPI fixture).

Aimock router patch persisted alongside the integration changes:
hasToolResult matcher restricted to scan only messages after the last
user message (was global). The patch lives in F:/projects/cpk/aimock —
upstream PR pending.

Test infrastructure:
- playwright.config.ts: cap local workers at 4 + retries at 1. CI keeps
  workers=1, retries=2. `agent_framework.Agent` is reused across requests
  and the shared OpenAI HTTP client serialises concurrent SSE streams;
  >4 workers makes 30s timeouts inevitable on a few cells. Confirmed
  with hard data: workers=1 = 164 passed (16.8 min), workers=4+retries=1
  = 164 passed (7.2 min), workers=undefined = 159 passed. Same green
  set, ~2x faster. Long-term upstream fix is per-request Agent
  instantiation in agent_framework_ag_ui.

Remaining 14 failures across 4 cells documented per-cell in the Notion
D5 sweep doc (declarative-gen-ui A2UI surface mounting, multimodal
attachment forwarding, tool-rendering-default-catchall multi-pill chain,
tool-rendering-reasoning-chain multi-leg chains). Each has a specific
next-pass action.
2026-05-19 18:36:01 +02:00

251 lines
8.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from "@playwright/test";
// QA reference: qa/tool-rendering-default-catchall.md
// Demo source: src/app/demos/tool-rendering-default-catchall/page.tsx
//
// This cell registers ZERO custom render hooks. The runtime falls back
// to the framework's built-in DefaultToolCallRenderer, which paints
// every tool call with a stable `[data-testid="copilot-tool-render"]`
// wrapper plus a `data-tool-name="<name>"` attribute. We assert on the
// built-in contract — branded testids from sibling cells stay at zero.
const SUGGESTION_TIMEOUT = 15000;
const TOOL_TIMEOUT = 60000;
const PILLS = ["Weather in SF", "Find flights", "Roll a d20", "Chain tools"];
test.describe("Tool Rendering — Default Catch-all", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/demos/tool-rendering-default-catchall");
await expect(page.getByPlaceholder("Type a message")).toBeVisible({
timeout: SUGGESTION_TIMEOUT,
});
});
test("page loads with composer and 4 suggestion pills", async ({ page }) => {
const suggestions = page.locator('[data-testid="copilot-suggestion"]');
for (const title of PILLS) {
await expect(suggestions.filter({ hasText: title }).first()).toBeVisible({
timeout: SUGGESTION_TIMEOUT,
});
}
// Sanity: branded sibling-cell testids stay at zero on this cell.
await expect(page.locator('[data-testid="weather-card"]')).toHaveCount(0);
await expect(page.locator('[data-testid="flights-card"]')).toHaveCount(0);
await expect(page.locator('[data-testid="stock-card"]')).toHaveCount(0);
await expect(page.locator('[data-testid="d20-card"]')).toHaveCount(0);
await expect(
page.locator('[data-testid="custom-wildcard-card"]'),
).toHaveCount(0);
});
test("Weather in SF pill paints the built-in default card for get_weather", async ({
page,
}) => {
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Weather in SF" })
.first()
.click();
const card = page
.locator(
'[data-testid="copilot-tool-render"][data-tool-name="get_weather"]',
)
.first();
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
// Args are pinned to San Francisco (verbatim pill prompt → fixture).
await expect
.poll(async () => card.getAttribute("data-args"), {
timeout: TOOL_TIMEOUT,
})
.toContain("San Francisco");
// No branded sibling-cell card mounted.
await expect(page.locator('[data-testid="weather-card"]')).toHaveCount(0);
await expect(
page.locator('[data-testid="custom-wildcard-card"]'),
).toHaveCount(0);
});
test("Find flights pill paints the built-in default card for search_flights", async ({
page,
}) => {
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Find flights" })
.first()
.click();
const card = page
.locator(
'[data-testid="copilot-tool-render"][data-tool-name="search_flights"]',
)
.first();
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
// Result attribute carries the deterministic fixture flights (NOT
// the a2ui beautiful-chat shape).
await expect
.poll(async () => card.getAttribute("data-result"), {
timeout: TOOL_TIMEOUT,
})
.toMatch(/United|Delta|JetBlue/);
await expect(page.locator('[data-testid="flights-card"]')).toHaveCount(0);
});
test("Roll a d20 pill paints exactly 5 default cards for roll_d20", async ({
page,
}) => {
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Roll a d20" })
.first()
.click();
const cards = page.locator(
'[data-testid="copilot-tool-render"][data-tool-name="roll_d20"]',
);
await expect
.poll(async () => cards.count(), { timeout: TOOL_TIMEOUT })
.toBe(5);
// 5th card's result must contain "20" (the final scripted roll).
const lastResult = await cards.nth(4).getAttribute("data-result");
expect(lastResult ?? "").toMatch(/"value":\s*20|"result":\s*20/);
// First 4 results are not-20.
for (let i = 0; i < 4; i++) {
const r = (await cards.nth(i).getAttribute("data-result")) ?? "";
expect(r).not.toMatch(/"value":\s*20|"result":\s*20/);
}
await expect(page.locator('[data-testid="d20-card"]')).toHaveCount(0);
});
test("Chain tools pill paints 3 default cards (weather + flights + d20)", async ({
page,
}) => {
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Chain tools" })
.first()
.click();
await expect(
page
.locator(
'[data-testid="copilot-tool-render"][data-tool-name="get_weather"]',
)
.first(),
).toBeVisible({ timeout: TOOL_TIMEOUT });
await expect(
page
.locator(
'[data-testid="copilot-tool-render"][data-tool-name="search_flights"]',
)
.first(),
).toBeVisible({ timeout: TOOL_TIMEOUT });
await expect(
page
.locator(
'[data-testid="copilot-tool-render"][data-tool-name="roll_d20"]',
)
.first(),
).toBeVisible({ timeout: TOOL_TIMEOUT });
});
// Regression for the aimock multi-pill bug:
// The d20 and Chain-tools fixtures used `turnIndex` + `hasToolResult` to
// disambiguate sequential iterations of the same prompt. Those gates
// count *global* thread state: clicking Find flights first left two
// assistant messages and one tool message behind, so the d20 loop
// entered at `turnIndex=2` (skipping rolls 7 and 14, hence only 3
// cards), and the Chain-tools tool-emitting fixture was skipped
// entirely (`hasToolResult: false` failed) so the pill went straight to
// the "Done — Tokyo is sunny…" content with no tool cards. Fix: chain
// all follow-ups via `toolCallId`, drop the global gates. This test
// drives the three offending pills in a single thread and asserts the
// expected card counts for each.
test("sequential pills in one thread render full card sequences for each", async ({
page,
}) => {
// Three sequential pills × multi-tool chains × LLM-mock latency easily
// exceeds Playwright's 30s default. Bumped to cover the worst case.
test.setTimeout(240_000);
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Find flights" })
.first()
.click();
const flights = page.locator(
'[data-testid="copilot-tool-render"][data-tool-name="search_flights"]',
);
await expect(flights).toHaveCount(1, { timeout: TOOL_TIMEOUT });
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Roll a d20" })
.first()
.click();
const d20 = page.locator(
'[data-testid="copilot-tool-render"][data-tool-name="roll_d20"]',
);
await expect
.poll(async () => d20.count(), { timeout: TOOL_TIMEOUT })
.toBe(5);
// Final scripted roll lands the 20 — proves the chain advanced through
// all 5 fixtures, not just the first two before bailing to content.
await expect
.poll(async () => d20.nth(4).getAttribute("data-result"), {
timeout: TOOL_TIMEOUT,
})
.toMatch(/"value":\s*20|"result":\s*20/);
await expect(page.getByText("Rolled the d20 five times")).toBeVisible({
timeout: TOOL_TIMEOUT,
});
});
test("every rendered card matches the built-in default-renderer DOM signature", async ({
page,
}) => {
// Drive a single pill that produces a single card so the assertions
// here are scoped to the exact DOM the framework's default renderer
// produces.
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Weather in SF" })
.first()
.click();
const card = page.locator('[data-testid="copilot-tool-render"]').first();
await expect(card).toBeVisible({ timeout: TOOL_TIMEOUT });
// The built-in default renderer always exposes name + status pill.
await expect(
card.locator('[data-testid="copilot-tool-render-name"]'),
).toBeVisible({ timeout: TOOL_TIMEOUT });
await expect(
card.locator('[data-testid="copilot-tool-render-status"]'),
).toBeVisible({ timeout: TOOL_TIMEOUT });
// Every card on the page shares the same wrapper testid count as
// the inner-name and inner-status testids — proves the built-in
// shell is what's painting (no per-tool shells).
const total = await page
.locator('[data-testid="copilot-tool-render"]')
.count();
await expect(
page.locator('[data-testid="copilot-tool-render-name"]'),
).toHaveCount(total);
await expect(
page.locator('[data-testid="copilot-tool-render-status"]'),
).toHaveCount(total);
});
});