Files
copilotkit__copilotkit/showcase/integrations/ms-agent-python/tests/e2e/tool-rendering-reasoning-chain.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

249 lines
9.5 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-reasoning-chain.md
// Demo source: src/app/demos/tool-rendering-reasoning-chain/page.tsx
//
// The reasoning-chain cell composes two patterns into one chat surface:
// - Reasoning-summary streaming (OpenAI Responses API, `reasoning={
// "effort":"medium","summary":"detailed"}`) rendered through a
// `messageView.reasoningMessage` slot (<ReasoningBlock>).
// - Per-tool renderers wired via `useRenderTool` for `get_weather` and
// `search_flights`, plus a `useDefaultRenderTool` catchall that
// paints `get_stock_price` and `roll_dice`.
//
// Every pill drives a CHAINED two-tool flow:
// - Stocks: get_stock_price(AAPL) → get_stock_price(MSFT) → comparison.
// - Dice: roll_dice(sides=20) → roll_dice(sides=6) → contrast.
// - Flights+weather: search_flights(SFO,JFK) → get_weather(JFK) → plan.
//
// Aimock fixtures live in showcase/aimock/d5-all.json (and the matching
// harness source at showcase/harness/fixtures/d5/tool-rendering-
// reasoning-chain.json) and pin every pill to a deterministic two-leg
// chain. The sequential-pills test is the regression guard for the
// AG-UI reasoning-role message bug in @copilotkit/runtime — without
// `LangGraphAgent.run`'s reasoning-role filter, clicking a second pill
// in the same thread used to crash with INCOMPLETE_STREAM because
// @ag-ui/langgraph's message converter throws on `role:"reasoning"`.
const SUGGESTION_TIMEOUT = 15_000;
const TOOL_TIMEOUT = 60_000;
const REASONING_TIMEOUT = 30_000;
const PILLS = [
"Compare two stocks",
"Chain of dice rolls",
"Flights + destination weather",
] as const;
test.describe("Tool Rendering — Reasoning Chain", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/demos/tool-rendering-reasoning-chain");
await expect(page.getByPlaceholder("Type a message")).toBeVisible({
timeout: SUGGESTION_TIMEOUT,
});
});
test("page loads with composer and 3 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: no per-tool cards mounted before any pill click.
await expect(page.locator('[data-testid="weather-card"]')).toHaveCount(0);
await expect(page.locator('[data-testid="flight-list-card"]')).toHaveCount(
0,
);
await expect(
page.locator('[data-testid="custom-catchall-card"]'),
).toHaveCount(0);
await expect(page.locator('[data-testid="reasoning-block"]')).toHaveCount(
0,
);
});
test("Compare two stocks pill chains AAPL → MSFT through the catchall renderer", async ({
page,
}) => {
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Compare two stocks" })
.first()
.click();
// Both legs of the chain mount via the catchall renderer — scoped
// by `data-tool-name` so we'd notice if a future per-tool stock
// renderer landed and only one card rendered.
const stockCards = page.locator(
'[data-testid="custom-catchall-card"][data-tool-name="get_stock_price"]',
);
await expect
.poll(async () => stockCards.count(), { timeout: TOOL_TIMEOUT })
.toBe(2);
// Reasoning slot mounts at least once — proves the agent's
// reasoning summaries reached the messageView slot, which is the
// whole reason this cell exists vs the plain tool-rendering demo.
await expect(
page.locator('[data-testid="reasoning-block"]').first(),
).toBeVisible({ timeout: REASONING_TIMEOUT });
// Narration text comes from the fixture final-content leg.
await expect(page.getByText("AAPL is at")).toBeVisible({
timeout: TOOL_TIMEOUT,
});
await expect(page.getByText("MSFT is at")).toBeVisible({
timeout: TOOL_TIMEOUT,
});
});
test("Chain of dice rolls pill chains d20 → d6 through the catchall renderer", async ({
page,
}) => {
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Chain of dice rolls" })
.first()
.click();
const diceCards = page.locator(
'[data-testid="custom-catchall-card"][data-tool-name="roll_dice"]',
);
await expect
.poll(async () => diceCards.count(), { timeout: TOOL_TIMEOUT })
.toBe(2);
await expect(
page.locator('[data-testid="reasoning-block"]').first(),
).toBeVisible({ timeout: REASONING_TIMEOUT });
// Final narration mentions both dice + the contrast framing.
await expect(page.getByText(/d20 came up/i)).toBeVisible({
timeout: TOOL_TIMEOUT,
});
});
test("Flights + destination weather pill chains search_flights → get_weather through branded per-tool renderers", async ({
page,
}) => {
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Flights + destination weather" })
.first()
.click();
// Flights card uses its branded renderer (not the catchall).
const flights = page.locator('[data-testid="flight-list-card"]').first();
await expect(flights).toBeVisible({ timeout: TOOL_TIMEOUT });
await expect(
flights.locator('[data-testid="flight-origin"]'),
).toContainText("SFO", { timeout: TOOL_TIMEOUT });
await expect(
flights.locator('[data-testid="flight-destination"]'),
).toContainText("JFK", { timeout: TOOL_TIMEOUT });
// Destination weather card uses its branded renderer.
const weather = page.locator('[data-testid="weather-card"]').first();
await expect(weather).toBeVisible({ timeout: TOOL_TIMEOUT });
await expect(weather.locator('[data-testid="weather-city"]')).toContainText(
"JFK",
{ timeout: TOOL_TIMEOUT },
);
await expect(
page.locator('[data-testid="reasoning-block"]').first(),
).toBeVisible({ timeout: REASONING_TIMEOUT });
// Catchall renderer must NOT mount for these tools — both have
// per-tool registrations.
await expect(
page.locator('[data-testid="custom-catchall-card"]'),
).toHaveCount(0);
});
// REGRESSION for the AG-UI reasoning-role message bug:
// `@ag-ui/langgraph`'s message converter throws "message role is
// not supported." on any role outside {user,assistant,system,tool}.
// Reasoning-stream agents emit `role:"reasoning"` messages that the
// AG-UI client replays on subsequent turns. Without the
// reasoning-role filter in @copilotkit/runtime's LangGraphAgent.run
// subclass, the SECOND pill click crashes before the model is
// called and the user sees a runtime error toast.
//
// This test clicks all three pills sequentially in ONE thread and
// asserts the full chain renders for each — proving cross-turn safety.
// It also catches a regression in any of:
// - The fixture toolCallId chains (degrading multi-pill to single
// tool calls).
// - The reasoning summary emission on follow-up turns.
// - Per-tool renderer state isolation between turns.
test("sequential pills in one thread render full chains + reasoning blocks for each", async ({
page,
}) => {
// Three sequential pills × 2-tool chains × LLM-mock latency easily
// exceeds Playwright's 30s default. Match the budget the
// tool-rendering-default-catchall multi-pill regression uses.
test.setTimeout(240_000);
const reasoningBlocks = page.locator('[data-testid="reasoning-block"]');
// Pill 1 — stocks chain.
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Compare two stocks" })
.first()
.click();
const stockCards = page.locator(
'[data-testid="custom-catchall-card"][data-tool-name="get_stock_price"]',
);
await expect
.poll(async () => stockCards.count(), { timeout: TOOL_TIMEOUT })
.toBe(2);
await expect
.poll(async () => reasoningBlocks.count(), { timeout: REASONING_TIMEOUT })
.toBeGreaterThanOrEqual(1);
// Pill 2 — dice chain. The KEY assertion: this used to crash with
// INCOMPLETE_STREAM before the reasoning-role filter landed.
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Chain of dice rolls" })
.first()
.click();
const diceCards = page.locator(
'[data-testid="custom-catchall-card"][data-tool-name="roll_dice"]',
);
await expect
.poll(async () => diceCards.count(), { timeout: TOOL_TIMEOUT })
.toBe(2);
// Reasoning blocks should have INCREASED — proves the second turn
// produced fresh reasoning, not just reusing turn 1's block.
await expect
.poll(async () => reasoningBlocks.count(), { timeout: REASONING_TIMEOUT })
.toBeGreaterThanOrEqual(2);
// Pill 3 — flights + destination weather. Final regression hop.
await page
.locator('[data-testid="copilot-suggestion"]')
.filter({ hasText: "Flights + destination weather" })
.first()
.click();
await expect(
page.locator('[data-testid="flight-list-card"]').first(),
).toBeVisible({ timeout: TOOL_TIMEOUT });
await expect(
page.locator('[data-testid="weather-card"]').first(),
).toBeVisible({ timeout: TOOL_TIMEOUT });
await expect
.poll(async () => reasoningBlocks.count(), { timeout: REASONING_TIMEOUT })
.toBeGreaterThanOrEqual(3);
// Final sanity: card counts for the prior turns survived (no
// unmounts mid-thread).
await expect(stockCards).toHaveCount(2);
await expect(diceCards).toHaveCount(2);
});
});