Skip to content

Commit 511adb0

Browse files
abossardCopilot
andcommitted
Add widget E2E tests + strict tools + Agent Chat JSON mode
New Playwright tests (23 total, +3): - 'renders bar-chart and pie-chart from x-ui annotations' — injects mock agent with output_schema containing x-ui widgets, verifies SVG rendering for pie/bar charts, stat-card with label, badges - 'renders raw JSON for object data' — verifies auto-detection: objects render as formatted JSON in pre blocks - 'falls back gracefully for non-JSON output' — verifies plain markdown string wraps as {message: text} and renders correctly Agent Chat (agents.py) fixes: - Added JSON output mode (response_format: json_object) - Added strict=True tool binding for compatibility - Matches the same pattern as agent_builder Strict tool binding (react_runner.py): - build_react_agent pre-binds tools with strict=True - Required for OpenAI JSON mode (response_format: json_object) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5069ca7 commit 511adb0

3 files changed

Lines changed: 207 additions & 5 deletions

File tree

backend/agent_builder/engine/react_runner.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,15 @@ def build_llm(
3333
temperature: float = 0.0,
3434
max_tokens: int = 0,
3535
) -> Any:
36-
"""Construct a ChatOpenAI instance with configurable parameters."""
36+
"""Construct a ChatOpenAI instance with JSON output mode."""
3737
from langchain_openai import ChatOpenAI
3838

3939
kwargs: dict[str, Any] = {
4040
"model": model,
4141
"api_key": api_key,
4242
"base_url": base_url or None,
4343
"temperature": temperature,
44+
"model_kwargs": {"response_format": {"type": "json_object"}},
4445
}
4546
if max_tokens > 0:
4647
kwargs["max_tokens"] = max_tokens
@@ -53,11 +54,18 @@ def build_react_agent(
5354
system_prompt: str,
5455
response_format: Any = None,
5556
) -> Any:
56-
"""Construct a LangGraph ReAct agent, optionally with structured output."""
57+
"""Construct a LangGraph ReAct agent with strict tool schemas.
58+
59+
Pre-binds tools with strict=True so they're compatible with OpenAI's
60+
JSON output mode (response_format: json_object).
61+
"""
5762
from langgraph.prebuilt import create_react_agent
5863

64+
# Pre-bind tools with strict=True for OpenAI JSON mode compatibility
65+
model_with_tools = llm.bind_tools(tools, strict=True) if tools else llm
66+
5967
kwargs: dict[str, Any] = {
60-
"model": llm,
68+
"model": model_with_tools,
6169
"tools": tools,
6270
"prompt": system_prompt,
6371
}

backend/agents.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,18 +200,21 @@ def __init__(self):
200200
"Please set OPENAI_API_KEY environment variable."
201201
)
202202

203-
# Initialize ChatOpenAI
203+
# Initialize ChatOpenAI with JSON output mode
204204
self.llm = ChatOpenAI(
205205
model=OPENAI_MODEL,
206206
api_key=OPENAI_API_KEY,
207207
base_url=OPENAI_BASE_URL or None,
208208
temperature=0.0,
209+
model_kwargs={"response_format": {"type": "json_object"}},
209210
)
210211

211212
# CSV tools only
212213
self.tools = self._build_csv_tools()
213214
self._system_prompt = self._build_system_prompt()
214-
self._react_agent = create_react_agent(self.llm, self.tools)
215+
# Pre-bind tools with strict=True for JSON mode compatibility
216+
model_with_tools = self.llm.bind_tools(self.tools, strict=True) if self.tools else self.llm
217+
self._react_agent = create_react_agent(model_with_tools, self.tools)
215218

216219
def _build_system_prompt(self) -> str:
217220
"""Build a concise system prompt optimized for low-latency tool usage."""

tests/e2e/workbench.spec.js

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,4 +457,195 @@ test.describe("SchemaRenderer widgets", () => {
457457
// Clean up
458458
await createdRow.getByRole("button", { name: "Delete" }).click();
459459
});
460+
461+
test("renders bar-chart and pie-chart from x-ui annotations", async ({ page }) => {
462+
const agentName = `e2e-charts-${Date.now()}`;
463+
const mockAgentId = `agent-charts-${Date.now()}`;
464+
const outputSchema = {
465+
type: "object",
466+
title: "ChartOutput",
467+
properties: {
468+
message: { type: "string", "x-ui": { widget: "markdown" } },
469+
status_distribution: {
470+
type: "object",
471+
description: "Tickets per status",
472+
"x-ui": { widget: "pie-chart" },
473+
},
474+
tickets_by_city: {
475+
type: "array",
476+
items: { type: "object", properties: { city: { type: "string" }, count: { type: "integer" } } },
477+
"x-ui": { widget: "bar-chart", indexBy: "city", keys: ["count"] },
478+
},
479+
total: { type: "integer", "x-ui": { widget: "stat-card", label: "Total Tickets" } },
480+
ticket_ids: { type: "array", items: { type: "string" }, "x-ui": { widget: "badge-list" } },
481+
},
482+
};
483+
484+
await page.route("**/api/workbench/agents/*/runs", async (route) => {
485+
await route.fulfill({
486+
status: 200,
487+
contentType: "application/json",
488+
body: JSON.stringify({
489+
id: "run-charts-1",
490+
agent_id: mockAgentId,
491+
input_prompt: "show charts",
492+
status: "completed",
493+
output: JSON.stringify({
494+
message: "## Dashboard\n\nTicket statistics overview.",
495+
status_distribution: { assigned: 43, in_progress: 45, pending: 115 },
496+
tickets_by_city: [
497+
{ city: "Bern", count: 103 },
498+
{ city: "Zollikofen", count: 26 },
499+
{ city: "Ittigen", count: 20 },
500+
],
501+
total: 206,
502+
ticket_ids: ["INC-100", "INC-200", "INC-300"],
503+
}, null, 2),
504+
agent_snapshot: { tool_names: ["csv_ticket_stats"] },
505+
tools_used: ["csv_ticket_stats"],
506+
error: null,
507+
created_at: "2026-03-04T10:00:00Z",
508+
completed_at: "2026-03-04T10:00:02Z",
509+
}),
510+
});
511+
});
512+
513+
// Inject mock agent with output_schema into agent list
514+
let realAgents = null;
515+
await page.route("**/api/workbench/agents", async (route) => {
516+
if (route.request().method() === "GET") {
517+
if (!realAgents) {
518+
const resp = await route.fetch();
519+
realAgents = (await resp.json()).agents || [];
520+
}
521+
const agents = [...realAgents];
522+
if (!agents.find(a => a.id === mockAgentId)) {
523+
agents.push({
524+
id: mockAgentId, name: agentName, description: "", system_prompt: "charts",
525+
tool_names: ["csv_ticket_stats"], output_schema: outputSchema,
526+
requires_input: false, required_input_description: "",
527+
model: "", temperature: 0, recursion_limit: 3, max_tokens: 4096,
528+
output_instructions: "", success_criteria: [],
529+
created_at: "2026-03-04T10:00:00Z", updated_at: "2026-03-04T10:00:00Z",
530+
});
531+
}
532+
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ agents }) });
533+
} else {
534+
await route.continue();
535+
}
536+
});
537+
538+
await page.goto(`${APP_URL}/workbench`, { waitUntil: "load" });
539+
await expect(page.getByTestId("workbench-page-title")).toBeVisible();
540+
541+
// Select mock agent and run
542+
await page.locator('[data-testid="workbench-run-agent-select"]').selectOption(mockAgentId);
543+
await page.getByTestId("workbench-run-agent-button").click();
544+
545+
const renderer = page.getByTestId("schema-renderer");
546+
await expect(renderer).toBeVisible({ timeout: 10000 });
547+
548+
// Verify markdown widget
549+
await expect(renderer.getByRole("heading", { name: "Dashboard" })).toBeVisible();
550+
551+
// Verify stat-card with label
552+
const statField = renderer.getByTestId("schema-field-total");
553+
await expect(statField).toBeVisible();
554+
await expect(statField.getByText("206")).toBeVisible();
555+
await expect(statField.getByText("Total Tickets").first()).toBeVisible();
556+
557+
// Verify badge-list
558+
await expect(renderer.getByText("INC-100")).toBeVisible();
559+
await expect(renderer.getByText("INC-300")).toBeVisible();
560+
561+
// Verify pie-chart (Nivo renders SVG)
562+
const pieField = renderer.getByTestId("schema-field-status_distribution");
563+
await expect(pieField).toBeVisible();
564+
await expect(pieField.locator("svg")).toBeVisible();
565+
566+
// Verify bar-chart (Nivo renders SVG)
567+
const barField = renderer.getByTestId("schema-field-tickets_by_city");
568+
await expect(barField).toBeVisible();
569+
await expect(barField.locator("svg")).toBeVisible();
570+
});
571+
572+
test("renders raw JSON for object data (auto-detected)", async ({ page }) => {
573+
const agentName = `e2e-json-${Date.now()}`;
574+
575+
await page.route("**/api/workbench/agents/*/runs", async (route) => {
576+
await route.fulfill({
577+
status: 200,
578+
contentType: "application/json",
579+
body: JSON.stringify({
580+
id: "run-json-1", agent_id: "agent-json-1", input_prompt: "raw",
581+
status: "completed",
582+
output: JSON.stringify({
583+
message: "Here is raw data.",
584+
metadata: { version: "1.0", source: "csv", processed_at: "2026-03-04" },
585+
}, null, 2),
586+
agent_snapshot: { tool_names: ["csv_ticket_stats"] },
587+
tools_used: ["csv_ticket_stats"], error: null,
588+
created_at: "2026-03-04T10:00:00Z", completed_at: "2026-03-04T10:00:01Z",
589+
}),
590+
});
591+
});
592+
593+
await page.goto(`${APP_URL}/workbench`, { waitUntil: "load" });
594+
await page.getByTestId("workbench-agent-name-input").fill(agentName);
595+
await page.getByTestId("workbench-agent-system-prompt-input").fill("Raw data");
596+
await page.getByTestId("workbench-create-agent-button").click();
597+
598+
const createdRow = page.locator('[data-testid="workbench-agents-table"] tbody tr', { hasText: agentName });
599+
await expect(createdRow).toBeVisible({ timeout: 10000 });
600+
await page.getByTestId("workbench-run-agent-button").click();
601+
602+
const renderer = page.getByTestId("schema-renderer");
603+
await expect(renderer).toBeVisible({ timeout: 10000 });
604+
await expect(renderer.getByText("Here is raw data.")).toBeVisible();
605+
606+
// metadata auto-detected as json (object → pre block)
607+
const metaField = renderer.getByTestId("schema-field-metadata");
608+
await expect(metaField).toBeVisible();
609+
await expect(metaField.locator("pre")).toBeVisible();
610+
await expect(metaField.getByText("csv")).toBeVisible();
611+
612+
await createdRow.getByRole("button", { name: "Delete" }).click();
613+
});
614+
615+
test("falls back gracefully for non-JSON output", async ({ page }) => {
616+
const agentName = `e2e-fallback-${Date.now()}`;
617+
618+
await page.route("**/api/workbench/agents/*/runs", async (route) => {
619+
await route.fulfill({
620+
status: 200,
621+
contentType: "application/json",
622+
body: JSON.stringify({
623+
id: "run-fb-1", agent_id: "agent-fb-1", input_prompt: "test",
624+
status: "completed",
625+
output: "# Plain Markdown\n\nThis is **not JSON** — just regular markdown.",
626+
agent_snapshot: { tool_names: ["csv_ticket_stats"] },
627+
tools_used: ["csv_ticket_stats"], error: null,
628+
created_at: "2026-03-04T10:00:00Z", completed_at: "2026-03-04T10:00:01Z",
629+
}),
630+
});
631+
});
632+
633+
await page.goto(`${APP_URL}/workbench`, { waitUntil: "load" });
634+
await page.getByTestId("workbench-agent-name-input").fill(agentName);
635+
await page.getByTestId("workbench-agent-system-prompt-input").fill("Fallback");
636+
await page.getByTestId("workbench-create-agent-button").click();
637+
638+
const createdRow = page.locator('[data-testid="workbench-agents-table"] tbody tr', { hasText: agentName });
639+
await expect(createdRow).toBeVisible({ timeout: 10000 });
640+
await page.getByTestId("workbench-run-agent-button").click();
641+
642+
const renderer = page.getByTestId("schema-renderer");
643+
await expect(renderer).toBeVisible({ timeout: 10000 });
644+
645+
// Non-JSON falls back: wrapped as {message: raw_text} → markdown
646+
await expect(renderer.getByRole("heading", { name: "Plain Markdown" })).toBeVisible();
647+
await expect(renderer.getByText("not JSON")).toBeVisible();
648+
649+
await createdRow.getByRole("button", { name: "Delete" }).click();
650+
});
460651
});

0 commit comments

Comments
 (0)