Compare commits

...

19 Commits

Author SHA1 Message Date
Ken c21e4e7f7b fix: browser integration for AI research agent
Deploy Research Workbench / deploy (push) Failing after 10m12s
- Dockerfile: Install Google Chrome stable instead of Playwright npm package
  (playwright-cli defaults to /opt/google/chrome/chrome path). Removed unused
  Playwright npm package, kept Playwright Python for page automation.

- bundle/bundle.md: Fix orchestrator config override with correct key path
  (session.orchestrator.config.extended_thinking: false) to properly override
  foundation's extended_thinking: true default, which was causing empty
  AI text responses.

- bundle/agents/researcher.md: Add strong pre-installed environment guardrail
  telling agent NOT to install packages. Add explicit bash() tool call examples
  showing correct playwright-cli syntax. Fix snapshot command (removed non-existent
  -ic flag), change 'type' to 'fill' for form field syntax.

- bundle/context/researcher-instructions.md: Enrich from thin pointer to
  concrete examples with correct bash() wrapping and actual playwright-cli
  commands. Add 'do NOT install' guardrail.

Validated: Agent now opens Chrome browser during research queries, noVNC panel
shows live browser activity, AI generates text responses properly.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-27 05:11:42 +00:00
Ken 11b3bc2bbf fix: chat functionality and extended thinking handling
Deploy Research Workbench / deploy (push) Failing after 14m1s
- bundle/bundle.md: Add provider-anthropic with source URL and orchestrator config override
  to disable extended_thinking (foundation enables it by default, causes empty text
  responses for simple queries)
- Dockerfile: Add amplifier-module-provider-anthropic pip install for container
- backend/agui_adapter.py: Fix thinking block handling and empty text block rendering
  - Thinking blocks no longer fall through to text handler
  - Text blocks only emit START when actual content arrives
  - Handle amplifierd wire format (block_index/block_type, complete text in content_block:end)
- frontend/src/hooks/useChat.ts: Reset assistantId on TEXT_MESSAGE_END to prevent stale
  message IDs across turns
- tests/test_agui_adapter.py: Add extended thinking test coverage

Chat functionality now works end-to-end. Verified: login -> create session -> send message -> receive AI response visible in chat UI.

Generated with Amplifier
2026-05-27 01:42:52 +00:00
Ken a21329bb04 ci: simplify deploy workflow - frontend now builds in Docker
Deploy Research Workbench / deploy (push) Failing after 14m27s
Removed npm ci/build step from workflow since the Dockerfile now
handles the full frontend build. Increased health check timeout to 180s.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-26 23:52:27 +00:00
Ken a84fd517a8 fix: remove duplicate amplifier-foundation dep from Dockerfile
Deploy Research Workbench / deploy (push) Failing after 10m24s
uv resolves conflicting URLs when amplifier-foundation is specified both
explicitly and as a transitive dep of amplifierd. Just install amplifierd
and let it resolve its own dependencies.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-26 23:51:31 +00:00
Ken c632a9d147 fix: Dockerfile — all build and runtime issues
Deploy Research Workbench / deploy (push) Failing after 12m28s
Sequential build failures fixed:
1. uv run playwright install chromium → playwright not in pyproject.toml/venv
   Fix: npm install -g @playwright/cli playwright && playwright install chromium
   Also installs @playwright/cli (the browser CLI used by research agents)

2. uv pip install amplifierd → package not on PyPI
   Fix: uv pip install --system from github.com/microsoft/amplifierd
   Also installs amplifier-foundation (also not on PyPI, git-only dep)
   Uses uv (not plain pip) to respect [tool.uv.sources] resolution

3. COPY frontend/dist/ → dist is gitignored, breaks fresh-clone builds
   Fix: build the frontend inside Docker using the installed Node.js
   (npm ci + npm run build); removes the pre-built dist assumption

Other fixes:
- Add git to apt-get (required for pip/uv git+https installs)
- Fix entrypoint.sh: amplifierd uses 'serve' subcommand and NAME=URI
  bundle format — was: amplifierd --port ... --bundle /path
  now: amplifierd serve --port ... --bundle name=/path --default-bundle name
- Add .dockerignore to exclude node_modules, .venv, dist, .git, etc.
  (keeps build context small and avoids stale artifacts in COPY steps)
2026-05-26 23:44:20 +00:00
Ken c6d8d4dfe6 fix: set UV_LINK_MODE=copy in Dockerfile for overlayfs compatibility
Deploy Research Workbench / deploy (push) Failing after 14m14s
uv's reflink-based file cloning fails with EAGAIN inside Docker's
overlayfs. Setting UV_LINK_MODE=copy forces regular file copies.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-26 23:07:40 +00:00
Ken d17b79ad95 ci: retrigger deploy (transient uv sync failure)
Deploy Research Workbench / deploy (push) Failing after 13m50s
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-26 22:53:04 +00:00
Ken a897d1cd00 fix: remove non-existent @anthropic-ai/playwright-cli from Dockerfile
Deploy Research Workbench / deploy (push) Failing after 13m37s
Package doesn't exist on npm. Playwright is already installed via
the Python backend deps (uv sync) and 'uv run playwright install chromium'.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-26 22:38:19 +00:00
Ken 7dd333b19d ci: add Gitea Actions deploy workflow
Deploy Research Workbench / deploy (push) Failing after 15m18s
Triggers on push to main. Builds frontend, stops old container,
builds and starts new Docker container, verifies the new UI is live.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-26 22:21:33 +00:00
Ken 389a8fb655 refactor: extract parseA2UIBlocks to lib/a2ui.ts; remove unused import re
- Extract parseA2UIBlocks() from A2UIRenderer.tsx into its own
  frontend/src/lib/a2ui.ts module to satisfy react-refresh/only-export-
  components ESLint rule (A2UIRenderer should only export React components)
- Update ChatPanel.tsx and test files to import from lib/a2ui
- Split ChatPanel.test.tsx mock: separate vi.mock for lib/a2ui
  (parseA2UIBlocks) and A2UIRenderer (component only)
- Remove unused 'import re' from tests/test_runtime_html.py (ruff F401)

All 216 frontend tests and 242 bash tests still pass. TypeScript and
Vite builds are clean. Remaining ESLint warnings (react-hooks/set-state-
in-effect in MCPAppRenderer and useSessions) are pre-existing patterns
not introduced by Phase 3.
2026-05-26 21:14:10 +00:00
Ken 2c3cfdbedc fix: resolve code review issues — schema mismatch, test fixes, pop-out render
- Fix a2ui-format.md schema to match renderer: rename 'options' → 'filters' in
  filters block, replace 'active: boolean' with 'value: string' in filter options,
  update form select options from string[] to {label, value}[] objects (Critical #1)
- Fix 3 failing tests in test_researcher_agent.sh to use @mention paths without .md
  extension (Important #2)
- Complete pop-out feature in VisualBlock.handlePopOut() by posting render message
  to popup window after 500ms initialization delay (Important #3)
- Update test_context_files.sh to match new a2ui-format.md schema (filters array
  key check, value field check)
2026-05-26 20:54:58 +00:00
Ken 92df88975c feat: docker-compose.yml for local development 2026-05-26 20:45:02 +00:00
Ken b895a1c94b feat: wire RightPanel with Browser + Artifacts, add A2UI rendering to chat
- RightPanel: replace placeholder content with BrowserPanel + ArtifactsPanel
  components, routing sessionId to whichever tab is active
- ChatPanel: import A2UIRenderer + parseA2UIBlocks; render A2UI blocks between
  markdown and tool-call cards in assistant messages; onAction sends
  '[action] {json}' back via onSend
- ChatPanel: add parseVisualBlocks() helper, VisualBlock wrapper component
  (loads runtime.html via MCPAppRenderer, view-source toggle, pop-out),
  rendered after A2UI blocks in assistant messages
- MCPAppRenderer: on ui/initialize route runtime='visual-artifact' data to a
  render dispatch instead of tool/input; add render forwarding case
- Fix pre-existing TS6133 unused-import errors in A2UIRenderer + BrowserPanel test
- All 216 tests pass; npm run build succeeds
2026-05-26 20:43:28 +00:00
Ken ab99259bcc feat: A2UI renderer for filters, tables, cards, forms 2026-05-26 20:29:42 +00:00
Ken a3635a9eb7 feat: artifacts panel with tabbed markdown rendering 2026-05-26 20:24:12 +00:00
Ken c1533fe25c feat: browser panel with noVNC iframe 2026-05-26 20:19:39 +00:00
Ken 74b2861db5 feat: complete bundle with visual artifact runtime and context 2026-05-26 20:16:02 +00:00
Ken 6538d0aa25 feat: visual artifacts context file with 5 example patterns for AI-generated visuals 2026-05-26 20:13:31 +00:00
Ken 0751fbfa0a feat: visual artifact runtime for AI-generated inline visualizations
- Add bundle/apps/runtime.html: universal runtime that executes
  AI-generated visualization code in a sandboxed context
- Pre-loads CDN libraries: Tabler CSS, Leaflet CSS+JS, Chart.js v4,
  React 19 UMD, ReactDOM 19 UMD, Babel standalone 7
- Implements mcpBridge with JSON-RPC 2.0 protocol:
  sendToHost, callTool (with UUID ids + Promise), sendMessage, resize
- executeVisual detects JSX/HTML/plain-JS paths via regex
- Auto-resize via requestAnimationFrame + scrollHeight + 32, capped at 800
- Initial ui/initialize handshake with runtime='visual-artifact' v1.0.0
- Listens for 'render' and legacy 'tool/input' methods
- Add tests/test_runtime_html.py: 61 tests covering all spec requirements
2026-05-26 20:08:22 +00:00
34 changed files with 3214 additions and 129 deletions
+14
View File
@@ -0,0 +1,14 @@
**/__pycache__
**/*.pyc
**/*.egg-info
.venv
backend/.venv
frontend/node_modules
frontend/dist
artifacts
.git
.gitea
.pytest_cache
.ruff_cache
tests
*.swp
+52
View File
@@ -0,0 +1,52 @@
name: Deploy Research Workbench
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Pull latest code
working-directory: /home/ken/workspace/research-workbench
run: |
git pull origin main
- name: Stop old containers
working-directory: /home/ken/workspace/research-workbench
run: |
docker rm -f car-help 2>/dev/null || true
docker compose down 2>/dev/null || true
- name: Build and start container
working-directory: /home/ken/workspace/research-workbench
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
AUTH_USER: ${{ secrets.AUTH_USER }}
AUTH_PASS_HASH: ${{ secrets.AUTH_PASS_HASH }}
run: |
docker compose up --build -d
- name: Wait for healthy
run: |
echo "Waiting for container to start..."
for i in $(seq 1 90); do
if curl -sf http://localhost:8080/ > /dev/null 2>&1; then
echo "App is up at http://localhost:8080/"
exit 0
fi
sleep 2
done
echo "ERROR: App did not respond within 180s"
docker compose -f /home/ken/workspace/research-workbench/docker-compose.yml logs --tail=50
exit 1
- name: Verify new UI is served
run: |
BODY=$(curl -sf http://localhost:8080/ || echo "FETCH_FAILED")
if echo "$BODY" | grep -qi "car-help"; then
echo "ERROR: Still serving old car-help UI!"
exit 1
fi
echo "Deploy successful - new UI is live at https://research.ampbox.io"
+37 -16
View File
@@ -1,12 +1,15 @@
FROM python:3.13-slim FROM python:3.13-slim
# Install system dependencies # ── System dependencies ────────────────────────────────────────────────────────
# Includes Xvfb/VNC stack, git (needed for pip's git+https installs),
# and Chromium runtime libs (for the playwright-managed Chromium binary).
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \ xvfb \
x11vnc \ x11vnc \
websockify \ websockify \
novnc \ novnc \
curl \ curl \
git \
libnss3 \ libnss3 \
libatk1.0-0 \ libatk1.0-0 \
libatk-bridge2.0-0 \ libatk-bridge2.0-0 \
@@ -25,40 +28,58 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-liberation \ fonts-liberation \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Node.js 22.x from nodesource.com # ── Node.js 22.x ──────────────────────────────────────────────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install @anthropic-ai/playwright-cli globally via npm # ── npm global tools: @playwright/cli + Chrome ────────────────────────────────
RUN npm install -g @anthropic-ai/playwright-cli # @playwright/cli — token-efficient browser CLI used by research agents
# (playwright-cli -s=research open/snapshot/click/type …)
# playwright-cli defaults to Chrome at /opt/google/chrome/chrome. Install it directly.
RUN npm install -g @playwright/cli \
&& curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o /tmp/chrome.deb \
&& apt-get update && apt-get install -y /tmp/chrome.deb \
&& rm -f /tmp/chrome.deb && rm -rf /var/lib/apt/lists/*
# Install uv via pip # ── Python tooling ────────────────────────────────────────────────────────────
RUN pip install uv RUN pip install uv
# overlayfs inside Docker doesn't support reflinks → use copy mode to avoid EAGAIN
ENV UV_LINK_MODE=copy
# Copy backend dependency files and install Python dependencies # ── amplifierd: Amplifier session daemon ──────────────────────────────────────
# Not on PyPI — source is github.com/microsoft/amplifierd.
# uv pip --system installs to /usr/local/bin/amplifierd (on PATH by default).
#
# amplifier-foundation is also not on PyPI (it's a pure git package that
# amplifierd depends on), so we install it explicitly in the same step.
# amplifier-core IS on PyPI and will be resolved automatically.
RUN uv pip install --system \
"amplifierd @ git+https://github.com/microsoft/amplifierd" \
"amplifier-module-provider-anthropic @ git+https://github.com/microsoft/amplifier-module-provider-anthropic"
# ── Frontend: install deps and build ──────────────────────────────────────────
# Copy package manifests first so npm ci is cached independently of source changes.
COPY frontend/package.json frontend/package-lock.json /app/frontend/
WORKDIR /app/frontend
RUN npm ci
COPY frontend/ /app/frontend/
RUN npm run build
# ── Backend: install Python deps ──────────────────────────────────────────────
COPY backend/pyproject.toml backend/uv.lock /app/backend/ COPY backend/pyproject.toml backend/uv.lock /app/backend/
WORKDIR /app/backend WORKDIR /app/backend
RUN uv sync --frozen RUN uv sync --frozen
# Install Playwright chromium # ── Application files ─────────────────────────────────────────────────────────
RUN uv run playwright install chromium
# Install amplifierd
RUN uv pip install amplifierd
# Copy application files
COPY backend/*.py /app/backend/ COPY backend/*.py /app/backend/
COPY bundle/ /app/bundle/ COPY bundle/ /app/bundle/
COPY frontend/dist/ /app/frontend/dist/
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
# Create artifacts directory
RUN mkdir -p /app/artifacts RUN mkdir -p /app/artifacts
WORKDIR /app WORKDIR /app
EXPOSE 8080 6080 EXPOSE 8080 6080
ENV DISPLAY=:99 ENV DISPLAY=:99
+80 -20
View File
@@ -40,13 +40,19 @@ class AmplifierdEventTranslator:
"""Translate a single amplifierd SSE event into zero or more AG-UI events. """Translate a single amplifierd SSE event into zero or more AG-UI events.
Args: Args:
event: Dict with 'event_type' and 'data' keys. event: Dict with 'event_type' and 'data' keys. The 'data' value is
the raw SSE payload from amplifierd, which wraps the event-specific
fields under a nested ``"data"`` key
(e.g. ``{"event": "...", "data": {...}, "session_id": ..., ...}``).
Returns: Returns:
List of ag_ui.core event objects (may be empty for unknown events). List of ag_ui.core event objects (may be empty for unknown events).
""" """
event_type = event.get("event_type", "") event_type = event.get("event_type", "")
data = event.get("data", {}) outer = event.get("data", {})
# amplifierd wraps event-specific fields in a nested "data" key.
# Fall back to the outer dict for tests that pass data directly.
data = outer.get("data", outer)
if event_type == "content_block:start": if event_type == "content_block:start":
return self._handle_block_start(data) return self._handle_block_start(data)
@@ -62,9 +68,13 @@ class AmplifierdEventTranslator:
return [] return []
def _handle_block_start(self, data: dict[str, Any]) -> list[Any]: def _handle_block_start(self, data: dict[str, Any]) -> list[Any]:
"""Handle content_block:start — emit TEXT_MESSAGE_START or TOOL_CALL_START.""" """Handle content_block:start — register block; defer TEXT_MESSAGE_START to first delta.
index = data.get("index", 0)
block_type = data.get("type", "text") Supports both amplifierd wire format (``block_index`` / ``block_type``) and the
legacy test format (``index`` / ``type``) via fallbacks.
"""
index = data.get("block_index", data.get("index", 0))
block_type = data.get("block_type", data.get("type", "text"))
if block_type == "tool_use": if block_type == "tool_use":
tool_id = data.get("id", "") tool_id = data.get("id", "")
@@ -81,21 +91,27 @@ class AmplifierdEventTranslator:
parent_message_id=self._current_message_id, parent_message_id=self._current_message_id,
) )
] ]
else: elif block_type == "text":
# Text block # Lazy start: don't emit TEXT_MESSAGE_START until we know there's actual content.
message_id = str(uuid.uuid4()) message_id = str(uuid.uuid4())
self._blocks[index] = {"type": "text", "message_id": message_id} self._blocks[index] = {"type": "text", "message_id": message_id, "started": False}
self._current_message_id = message_id self._current_message_id = message_id
return [ return []
TextMessageStartEvent( else:
message_id=message_id, # thinking, image, or any other block type — skip entirely
role="assistant", self._blocks[index] = {"type": "skip"}
) return []
]
def _handle_block_delta(self, data: dict[str, Any]) -> list[Any]: def _handle_block_delta(self, data: dict[str, Any]) -> list[Any]:
"""Handle content_block:delta — emit TEXT_MESSAGE_CONTENT or TOOL_CALL_ARGS.""" """Handle content_block:delta — emit TEXT_MESSAGE_CONTENT or TOOL_CALL_ARGS.
index = data.get("index", 0)
For text blocks, TEXT_MESSAGE_START is emitted lazily here on the first
non-empty token so that thinking/empty text blocks produce zero AG-UI events.
Supports both amplifierd wire format (``block_index``) and legacy test format
(``index``) via fallback.
"""
index = data.get("block_index", data.get("index", 0))
delta = data.get("delta", {}) delta = data.get("delta", {})
delta_type = delta.get("type", "") delta_type = delta.get("type", "")
@@ -112,6 +128,9 @@ class AmplifierdEventTranslator:
] ]
return [] return []
if block_info["type"] == "skip":
return []
if block_info["type"] == "tool_use": if block_info["type"] == "tool_use":
return [ return [
ToolCallArgsEvent( ToolCallArgsEvent(
@@ -120,10 +139,22 @@ class AmplifierdEventTranslator:
) )
] ]
else: else:
# Text block # Text block — lazy start: emit TEXT_MESSAGE_START on the first real token
token = delta.get("text", "") token = delta.get("text", "")
if not token: if not token:
return [] return []
if not block_info.get("started"):
block_info["started"] = True
return [
TextMessageStartEvent(
message_id=block_info["message_id"],
role="assistant",
),
TextMessageContentEvent(
message_id=block_info["message_id"],
delta=token,
),
]
return [ return [
TextMessageContentEvent( TextMessageContentEvent(
message_id=block_info["message_id"], message_id=block_info["message_id"],
@@ -132,17 +163,46 @@ class AmplifierdEventTranslator:
] ]
def _handle_block_end(self, data: dict[str, Any]) -> list[Any]: def _handle_block_end(self, data: dict[str, Any]) -> list[Any]:
"""Handle content_block:end — emit TEXT_MESSAGE_END or TOOL_CALL_END.""" """Handle content_block:end — emit TEXT_MESSAGE_END or TOOL_CALL_END.
index = data.get("index", 0)
Supports two modes:
- **Streaming mode** (delta events were received): the block was already opened
via ``_handle_block_delta``; just close it with TEXT_MESSAGE_END.
- **Batch mode** (no delta events, full text in ``block.text``): emit
TEXT_MESSAGE_START + TEXT_MESSAGE_CONTENT + TEXT_MESSAGE_END in one shot.
This is the format used by amplifierd, which sends complete blocks at end time.
Text blocks with no content in either mode are silently dropped.
Supports both amplifierd wire format (``block_index``) and legacy test format
(``index``) via fallback.
"""
index = data.get("block_index", data.get("index", 0))
block_info = self._blocks.pop(index, None) block_info = self._blocks.pop(index, None)
if block_info is None: if block_info is None:
return [] return []
if block_info["type"] == "skip":
return []
if block_info["type"] == "tool_use": if block_info["type"] == "tool_use":
return [ToolCallEndEvent(tool_call_id=block_info["tool_id"])] return [ToolCallEndEvent(tool_call_id=block_info["tool_id"])]
else: else:
return [TextMessageEndEvent(message_id=block_info["message_id"])] message_id = block_info["message_id"]
if block_info.get("started"):
# Streaming mode: deltas already opened the message — just close it.
return [TextMessageEndEvent(message_id=message_id)]
# Batch mode: full text is in the "block" field (amplifierd wire format).
block = data.get("block", {})
text = block.get("text", "")
if text:
return [
TextMessageStartEvent(message_id=message_id, role="assistant"),
TextMessageContentEvent(message_id=message_id, delta=text),
TextMessageEndEvent(message_id=message_id),
]
return []
def _handle_tool_result(self, data: dict[str, Any]) -> list[Any]: def _handle_tool_result(self, data: dict[str, Any]) -> list[Any]:
"""Handle tool:result — emit TOOL_CALL_RESULT with optional MCP App metadata.""" """Handle tool:result — emit TOOL_CALL_RESULT with optional MCP App metadata."""
View File
+21 -8
View File
@@ -97,11 +97,24 @@ extract current data, and synthesize findings into clear, actionable results. Th
browser runs persistently — tabs stay open between commands, and the user can watch browser runs persistently — tabs stay open between commands, and the user can watch
and take over at any time via the noVNC view. and take over at any time via the noVNC view.
## IMPORTANT: Pre-installed Environment
`playwright-cli` is **already installed** in this container. Do NOT install anything.
Do NOT run `npm install`, `pip install`, or any package manager. Just use the commands
below directly via the bash tool.
## Browser Control (playwright-cli) ## Browser Control (playwright-cli)
You control the browser using `playwright-cli` with a persistent named session You control the browser using `playwright-cli` via the bash tool. All commands use
(`-s=research`). All commands operate on the same browser session, preserving tabs a persistent named session (`-s=research`), preserving tabs and state across the
and state across the entire conversation. entire conversation.
**How to invoke:** Every playwright-cli command runs through the bash tool:
```
bash("playwright-cli -s=research open https://example.com")
bash("playwright-cli -s=research snapshot")
bash("playwright-cli -s=research click @e5")
```
### Commands ### Commands
@@ -109,9 +122,9 @@ and state across the entire conversation.
|--------|---------| |--------|---------|
| Open a URL | `playwright-cli -s=research open https://example.com` | | Open a URL | `playwright-cli -s=research open https://example.com` |
| Take a snapshot | `playwright-cli -s=research snapshot` | | Take a snapshot | `playwright-cli -s=research snapshot` |
| Compact snapshot | `playwright-cli -s=research snapshot -ic` |
| Click an element | `playwright-cli -s=research click @e5` | | Click an element | `playwright-cli -s=research click @e5` |
| Type text | `playwright-cli -s=research type @e3 "search query"` | | Type text | `playwright-cli -s=research type "search query"` |
| Fill a field | `playwright-cli -s=research fill @e3 "search query"` |
| Press a key | `playwright-cli -s=research press Enter` | | Press a key | `playwright-cli -s=research press Enter` |
| Scroll down | `playwright-cli -s=research scroll down` | | Scroll down | `playwright-cli -s=research scroll down` |
| Go back | `playwright-cli -s=research back` | | Go back | `playwright-cli -s=research back` |
@@ -150,7 +163,7 @@ You can generate inline UI controls — filter toggles, sortable tables, card gr
forms — that render directly in the chat. Use A2UI for simple interactive result forms — that render directly in the chat. Use A2UI for simple interactive result
presentation when the data is straightforward. presentation when the data is straightforward.
@mention bundle/context/a2ui-format.md @mention bundle/context/a2ui-format
## Artifacts ## Artifacts
@@ -158,7 +171,7 @@ For research worth saving — comparison guides, reports, curated lists — gene
persistent artifact documents. Artifacts appear in the right panel and survive persistent artifact documents. Artifacts appear in the right panel and survive
across sessions. across sessions.
@mention bundle/context/artifact-conventions.md @mention bundle/context/artifact-conventions
## Inline Visuals (Claude-style) ## Inline Visuals (Claude-style)
@@ -173,7 +186,7 @@ Good candidates for inline visual generation:
- **Scatter plots** for price vs. rating or mileage vs. price analysis - **Scatter plots** for price vs. rating or mileage vs. price analysis
- **Timeline charts** for flight options by departure time - **Timeline charts** for flight options by departure time
@mention bundle/context/visual-artifacts.md @mention bundle/context/visual-artifacts
## Research Workflow ## Research Workflow
+202
View File
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visual Artifact Runtime</title>
<!-- Tabler CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css" />
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.css" />
<!-- Chart.js v4 -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<!-- Leaflet JS -->
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.js"></script>
<!-- React 19 production UMD -->
<script src="https://cdn.jsdelivr.net/npm/react@19/umd/react.production.min.js"></script>
<!-- ReactDOM 19 production UMD -->
<script src="https://cdn.jsdelivr.net/npm/react-dom@19/umd/react-dom.production.min.js"></script>
<!-- Babel standalone 7 for JSX transpilation -->
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<style>
html, body {
background: transparent;
color-scheme: dark;
color: #e0e0e0;
margin: 0;
padding: 16px;
font-family: system-ui, -apple-system, sans-serif;
}
.visual-error {
color: #ff4444;
background: #1a0000;
font-family: monospace;
padding: 12px 16px;
white-space: pre-wrap;
border-radius: 4px;
border: 1px solid #550000;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
// ─── MCP Bridge ───────────────────────────────────────────────────────────
const mcpBridge = {
_pending: new Map(),
sendToHost(msg) {
window.parent.postMessage(msg, "*");
},
callTool(name, args) {
const id = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
return new Promise((resolve, reject) => {
mcpBridge._pending.set(id, { resolve, reject });
mcpBridge.sendToHost({
jsonrpc: "2.0",
method: "tools/call",
id,
params: { name, arguments: args },
});
});
},
sendMessage(content) {
mcpBridge.sendToHost({
jsonrpc: "2.0",
method: "ui/sendMessage",
params: { role: "user", content },
});
},
resize(height) {
mcpBridge.sendToHost({
jsonrpc: "2.0",
method: "ui/resize",
params: { height: Math.min(height, 800) },
});
},
};
// ─── Execute Visual ───────────────────────────────────────────────────────
function executeVisual(code, data) {
const root = document.getElementById("root");
window.__VISUAL_DATA__ = data || {};
window.__MCP__ = mcpBridge;
try {
const isJSX = (
/<[A-Z]/.test(code) ||
/React\.createElement/.test(code) ||
/type="text\/babel"/.test(code)
);
const isHTML = !isJSX && /<[a-z]/.test(code);
if (isJSX) {
// JSX path — compile with Babel then render
const compiled = Babel.transform(code, {
presets: ["react"],
filename: "visual.jsx",
}).code;
const autoRender = `
if (typeof App !== 'undefined') {
ReactDOM.createRoot(root).render(React.createElement(App, { data, mcp }));
}
`;
const fn = new Function("React", "ReactDOM", "data", "mcp", "root", compiled + "\n" + autoRender);
fn(React, ReactDOM, window.__VISUAL_DATA__, mcpBridge, root);
} else if (isHTML) {
// HTML path — inject markup and re-execute inline scripts
root.innerHTML = code;
// Re-execute all script tags (browsers don't execute scripts set via innerHTML)
const scripts = root.querySelectorAll("script");
scripts.forEach((oldScript) => {
const newScript = document.createElement("script");
if (oldScript.src) {
// External script — preserve src
newScript.src = oldScript.src;
} else {
// Inline script — wrap with data/mcp context
const body = oldScript.textContent;
newScript.textContent = `(function(data, mcp) {\n${body}\n})(window.__VISUAL_DATA__, window.__MCP__);`;
}
oldScript.parentNode.replaceChild(newScript, oldScript);
});
} else {
// Plain JS path — execute with explicit arguments
const fn = new Function("data", "mcp", "root", "Chart", "L", code);
fn(window.__VISUAL_DATA__, mcpBridge, root, window.Chart, window.L);
}
// Auto-resize after render
requestAnimationFrame(() => {
mcpBridge.resize(Math.max(root.scrollHeight + 32, 100));
});
} catch (err) {
// Render error state
const errorDiv = document.createElement("div");
errorDiv.className = "visual-error";
errorDiv.textContent = String(err);
root.innerHTML = "";
root.appendChild(errorDiv);
mcpBridge.resize(120);
}
}
// ─── Message Listener ─────────────────────────────────────────────────────
window.addEventListener("message", (event) => {
const msg = event.data;
if (!msg) return;
// Response to a pending tools/call
if (msg.id && mcpBridge._pending.has(msg.id)) {
const { resolve } = mcpBridge._pending.get(msg.id);
mcpBridge._pending.delete(msg.id);
resolve(msg.result ?? msg);
return;
}
// Render command
if (msg.method === "render") {
const code = (msg.params && msg.params.code) || "";
const data = (msg.params && msg.params.data) || {};
executeVisual(code, data);
return;
}
// Legacy: tool/input with a code param treated as render
if (msg.method === "tool/input" && msg.params && msg.params.code) {
executeVisual(msg.params.code, msg.params.data || {});
return;
}
});
// ─── Initial Handshake ────────────────────────────────────────────────────
mcpBridge.sendToHost({
jsonrpc: "2.0",
method: "ui/initialize",
params: {
runtime: "visual-artifact",
version: "1.0.0",
},
});
</script>
</body>
</html>
View File
+11
View File
@@ -4,6 +4,17 @@ bundle:
version: 0.1.0 version: 0.1.0
description: General-purpose AI research tool with browser control and artifact generation description: General-purpose AI research tool with browser control and artifact generation
providers:
- module: provider-anthropic
source: git+https://github.com/microsoft/amplifier-module-provider-anthropic@main
config:
default_model: claude-sonnet-4-20250514
session:
orchestrator:
config:
extended_thinking: false
includes: includes:
- bundle: git+https://github.com/microsoft/amplifier-foundation@main - bundle: git+https://github.com/microsoft/amplifier-foundation@main
- bundle: research-workbench:behaviors/research-workbench - bundle: research-workbench:behaviors/research-workbench
View File
+14 -10
View File
@@ -28,20 +28,20 @@ Renders a row of toggle chips. Use for quick category or attribute filtering.
```a2ui ```a2ui
{ {
"type": "filters", "type": "filters",
"options": [ "filters": [
{ "label": "Toyota", "active": true }, { "label": "Toyota", "value": "toyota" },
{ "label": "Honda", "active": false }, { "label": "Honda", "value": "honda" },
{ "label": "Hyundai", "active": false }, { "label": "Hyundai", "value": "hyundai" },
{ "label": "Under $5K", "active": false } { "label": "Under $5K", "value": "under_5k" }
] ]
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `options` | array | List of filter chips | | `filters` | array | List of filter chips |
| `options[].label` | string | Display text for the chip | | `filters[].label` | string | Display text for the chip |
| `options[].active` | boolean | Whether the chip is pre-selected | | `filters[].value` | string | Machine-readable identifier for the chip |
--- ---
@@ -113,7 +113,11 @@ configure a follow-up action.
"name": "fuel_type", "name": "fuel_type",
"type": "select", "type": "select",
"label": "Fuel Type", "label": "Fuel Type",
"options": ["hybrid", "electric", "any"] "options": [
{ "label": "Hybrid", "value": "hybrid" },
{ "label": "Electric", "value": "electric" },
{ "label": "Any", "value": "any" }
]
} }
], ],
"submitLabel": "Search" "submitLabel": "Search"
@@ -127,7 +131,7 @@ configure a follow-up action.
| `fields[].type` | string | `"number"`, `"text"`, `"select"` | | `fields[].type` | string | `"number"`, `"text"`, `"select"` |
| `fields[].label` | string | Human-readable label | | `fields[].label` | string | Human-readable label |
| `fields[].value` | any | Default value (for `number`/`text` fields) | | `fields[].value` | any | Default value (for `number`/`text` fields) |
| `fields[].options` | string[] | Choice list (for `select` fields) | | `fields[].options` | `{label, value}[]` | Choice list (for `select` fields); each item has a display `label` and machine-readable `value` |
| `submitLabel` | string | Text on the submit button | | `submitLabel` | string | Text on the submit button |
--- ---
+44 -10
View File
@@ -3,22 +3,56 @@
This session runs inside the **Research Workbench**. You have access to the This session runs inside the **Research Workbench**. You have access to the
following capabilities: following capabilities:
## Available Capabilities ## CRITICAL: Pre-installed Tools
**Browser** — Live Chromium browser controlled via `playwright-cli -s=research`. All tools are **already installed**. Do NOT run npm install, pip install, or any
The browser is persistent across commands and visible to the user via VNC (noVNC package manager. Just use the commands directly via the bash tool.
panel). The user can take over at any time.
**Artifacts** — Persistent markdown documents written to `artifacts/<session_id>/`. ## Browser Control
Live Chromium browser controlled via `playwright-cli -s=research` through the
bash tool. The browser is persistent across commands and visible to the user via
the noVNC panel. The user can take over at any time.
**Quick start — open a site and see what's on it:**
```
bash("playwright-cli -s=research open https://google.com")
bash("playwright-cli -s=research snapshot")
```
**Click an element from the snapshot:**
```
bash("playwright-cli -s=research click @e5")
bash("playwright-cli -s=research snapshot")
```
**Type into a field and submit:**
```
bash("playwright-cli -s=research fill @e3 \"search query\"")
bash("playwright-cli -s=research press Enter")
bash("playwright-cli -s=research snapshot")
```
Always snapshot after navigation to get fresh element refs.
## Artifacts
Persistent markdown documents written to `artifacts/<session_id>/`.
They appear in the right panel and survive across sessions. They appear in the right panel and survive across sessions.
**A2UI** — Inline interactive controls (filter chips, tables, card grids, forms) ## A2UI
Inline interactive controls (filter chips, tables, card grids, forms)
rendered directly in chat via `a2ui` code blocks. rendered directly in chat via `a2ui` code blocks.
**Inline Visuals** — Rich visual output (charts, maps, comparison tables with color ## Inline Visuals
Rich visual output (charts, maps, comparison tables with color
coding) rendered directly in chat via `visual` code blocks. coding) rendered directly in chat via `visual` code blocks.
## Full Command Reference ## Research Workflow
For complete command syntax, workflow guidance, and best practices, see the 1. Understand the request — clarify scope and constraints
researcher agent system prompt. 2. Plan — identify 2-4 sources to consult
3. Browse and extract — use playwright-cli via bash to navigate sites and extract data
4. Present — use A2UI, visuals, or artifacts to deliver results
+274
View File
@@ -0,0 +1,274 @@
# Visual Artifacts (Inline Visuals)
Visual artifacts let you generate rich, interactive charts, maps, tables, and dashboards
directly in the chat. Write a `visual` fenced code block containing JSON with a `code`
field, and the frontend renders it live inside a sandboxed iframe using pre-loaded libraries
— no installs, no API fetches, no user setup required.
Use visuals proactively. When data benefits from a chart, table, or map, generate the visual
without waiting to be asked.
---
## When to Generate a Visual
Generate a visual whenever the response includes structured data that communicates better
visually than as prose. Triggers:
| Situation | Visual type |
|---|---|
| Comparing options (cars, tools, services) | Comparison table, bar chart |
| Showing trends over time | Line chart |
| Displaying locations or dealers | Map with markers |
| Scoring or ranking items | Bar chart, sorted table |
| Budget breakdowns or proportions | Pie/doughnut chart |
| Dashboards summarizing research | React dashboard with stat cards |
| Decision-making with weighted criteria | Decision matrix with sliders |
**Be proactive.** Don't wait to be asked. If you're returning 4+ items with comparable
attributes, a visual table is almost always better than a markdown list.
---
## Available Libraries (pre-loaded in the runtime)
| Library | Version | Global | Capabilities |
|---|---|---|---|
| Tabler CSS | 1.0.0-beta20 | *(stylesheet)* | cards, tables, badges, buttons, forms, grid, dark theme |
| Chart.js | v4 | `Chart` | bar, line, pie, radar, doughnut, scatter, polar area |
| Leaflet.js | v1.9 | `L` | maps with markers, popups, tile layers (CARTO dark) |
| React | v19 | `React` | interactive components with state |
| ReactDOM | v19 | `ReactDOM` | DOM rendering for React components |
| Babel | v7 | *(automatic)* | JSX compilation when JSX syntax is detected |
---
## Output Format
Wrap your visual in a fenced code block with the `visual` language tag. The content must be
a JSON object:
````
```visual
{
"title": "Display title shown above the visual",
"height": 300,
"data": { "items": [] },
"code": "<div class='card'>...</div><script>...</script>"
}
```
````
### Fields
| Field | Required | Default | Max | Description |
|---|---|---|---|---|
| `title` | yes | — | — | Display header shown above the iframe |
| `code` | yes | — | — | Complete HTML, JSX, or plain JS to execute |
| `data` | no | `{}` | — | Structured object passed into code as `data` |
| `height` | no | 300 | 800 | Iframe height in pixels |
---
## Code Formats
The runtime auto-detects the code format and handles it appropriately:
### 1. React / JSX
**Detection:** code contains `<ComponentName` (uppercase tag) or `React.createElement`
Babel compiles the code, then the runtime renders it into `#root`. Define an `App`
component and it auto-renders:
```
function App() {
return <div className="card">Hello</div>;
}
// App is automatically rendered to #root
```
### 2. HTML
**Detection:** code contains `<div`, `<table`, `<canvas`, or similar lowercase HTML tags
The HTML is injected into `#root`. Any `<script>` tags execute with `data` and `mcp`
available in scope.
```
<div id="my-table"></div>
<script>
document.getElementById('my-table').innerHTML = data.items.map(i => i.name).join(', ');
</script>
```
### 3. Plain JS
**Detection:** code is pure JavaScript without HTML or JSX tags
Executed as a function receiving `(data, mcp, root, Chart, L)` as parameters:
```
const canvas = document.createElement('canvas');
root.appendChild(canvas);
new Chart(canvas, { type: 'bar', data: { labels: data.labels, datasets: [...] } });
```
---
## Available in Code Scope
| Variable | Type | Description |
|---|---|---|
| `data` | Object | The `data` field from your JSON (or `{}` if omitted) |
| `mcp` | Object | Bridge to the host: `sendMessage(text)`, `callTool(name, args)`, `resize(height)` |
| `root` | Element | The `#root` DOM element — your render target |
| `Chart` | Class | Chart.js v4 constructor |
| `L` | Object | Leaflet.js v1.9 namespace |
**`mcp.sendMessage(text)`** — sends a user message back to the AI, enabling click-to-explore.
**`mcp.callTool(name, args)`** — calls an MCP tool and returns a Promise.
**`mcp.resize(height)`** — resizes the iframe (max 800px).
---
## Example Patterns
### 1. Comparison Table (HTML + Tabler CSS)
````
```visual
{
"title": "Hybrid Cars Under $10K",
"height": 280,
"data": {
"items": [
{ "model": "Toyota Prius", "year": 2015, "price": "$8,500", "miles": "72,000", "mpg": 50, "source": "https://cargurus.com" },
{ "model": "Honda Civic Hybrid", "year": 2014, "price": "$7,200", "miles": "89,000", "mpg": 44, "source": "https://autotrader.com" },
{ "model": "Toyota Camry Hybrid", "year": 2013, "price": "$9,400", "miles": "95,000", "mpg": 41, "source": "https://cars.com" },
{ "model": "Ford Fusion Hybrid", "year": 2014, "price": "$8,100", "miles": "81,000", "mpg": 47, "source": "https://cargurus.com" }
]
},
"code": "<div class='theme-dark' style='background:transparent;padding:8px'><div class='card' style='background:#1a1f2e;border-color:#2d3561'><div class='card-body p-2'><div class='table-responsive'><table class='table table-dark table-hover table-sm mb-0'><thead><tr><th>Model</th><th>Year</th><th style='color:#7af7a2'>Price</th><th>Miles</th><th>MPG</th><th>Source</th></tr></thead><tbody id='rows'></tbody></table></div></div></div></div><script>var tbody=document.getElementById('rows');data.items.forEach(function(car){var tr=document.createElement('tr');tr.innerHTML='<td>'+car.model+'</td><td>'+car.year+'</td><td style=\"color:#7af7a2\">'+car.price+'</td><td>'+car.miles+'</td><td>'+car.mpg+'</td><td><a href=\"'+car.source+'\" style=\"color:#7aa2f7\" target=\"_blank\">link</a></td>';tbody.appendChild(tr);});</script>"
}
```
````
---
### 2. Bar Chart (Plain JS + Chart.js)
````
```visual
{
"title": "Price Comparison by Model",
"height": 380,
"data": {
"labels": ["Prius 2015", "Civic Hybrid 2014", "Camry Hybrid 2013", "Fusion Hybrid 2014"],
"values": [8500, 7200, 9400, 8100]
},
"code": "var canvas = document.createElement('canvas'); root.appendChild(canvas); new Chart(canvas, { type: 'bar', data: { labels: data.labels, datasets: [{ label: 'Price ($)', data: data.values, backgroundColor: 'rgba(122,162,247,0.7)', borderColor: 'rgba(122,162,247,1)', borderWidth: 1 }] }, options: { responsive: true, plugins: { legend: { labels: { color: '#c0c0d0' } } }, scales: { x: { ticks: { color: '#8888aa' }, grid: { color: '#1e2a4a' } }, y: { ticks: { color: '#8888aa' }, grid: { color: '#1e2a4a' } } }, onClick: function(evt, elements) { if (elements.length > 0) { var label = data.labels[elements[0].index]; mcp.sendMessage('Tell me more about ' + label); } } } });"
}
```
````
---
### 3. Location Map (Plain JS + Leaflet)
````
```visual
{
"title": "Dealers Near Woodinville, WA",
"height": 380,
"data": {
"center": [47.754, -122.163],
"zoom": 11,
"markers": [
{ "lat": 47.754, "lng": -122.163, "title": "Woodinville Auto Center", "description": "Large hybrid selection", "price": "$7,500$11,000" },
{ "lat": 47.770, "lng": -122.205, "title": "Kirkland Motors", "description": "Certified pre-owned", "price": "$8,000$12,000" },
{ "lat": 47.720, "lng": -122.130, "title": "Redmond Auto Group", "description": "Best for Prius models", "price": "$7,200$10,500" }
]
},
"code": "root.style.height = '340px'; var map = L.map(root).setView(data.center, data.zoom); L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '&copy; CARTO', subdomains: 'abcd', maxZoom: 19 }).addTo(map); var bounds = []; data.markers.forEach(function(m) { var marker = L.marker([m.lat, m.lng]).addTo(map); marker.bindPopup('<b>' + m.title + '</b><br>' + m.description + '<br><span style=\"color:#7af7a2\">' + m.price + '</span>'); marker.on('click', function() { mcp.sendMessage('Tell me about ' + m.title); }); bounds.push([m.lat, m.lng]); }); if (bounds.length > 1) { map.fitBounds(bounds, { padding: [20, 20] }); }"
}
```
````
---
### 4. React Dashboard (JSX)
````
```visual
{
"title": "Research Summary Dashboard",
"height": 450,
"data": {
"cars": [
{ "model": "Toyota Prius 2015", "price": "$8,500", "priceNum": 8500, "miles": 72000, "mpg": 50 },
{ "model": "Honda Civic Hybrid 2014", "price": "$7,200", "priceNum": 7200, "miles": 89000, "mpg": 44 },
{ "model": "Toyota Camry Hybrid 2013", "price": "$9,400", "priceNum": 9400, "miles": 95000, "mpg": 41 },
{ "model": "Ford Fusion Hybrid 2014", "price": "$8,100", "priceNum": 8100, "miles": 81000, "mpg": 47 }
]
},
"code": "function StatCard({ label, value }) { return React.createElement('div', { className: 'col-4' }, React.createElement('div', { className: 'card', style: { background: '#1a1f2e', borderColor: '#2d3561' } }, React.createElement('div', { className: 'card-body text-center p-2' }, React.createElement('div', { style: { fontSize: '1.5rem', fontWeight: 'bold', color: '#7af7a2' } }, value), React.createElement('div', { style: { fontSize: '0.75rem', color: '#8888aa' } }, label)))); } function App() { var [sortKey, setSortKey] = React.useState('price'); var cars = data.cars.slice().sort(function(a, b) { return sortKey === 'price' ? a.priceNum - b.priceNum : a.miles - b.miles; }); var avgPrice = Math.round(data.cars.reduce(function(s, c) { return s + c.priceNum; }, 0) / data.cars.length); var minP = Math.min.apply(null, data.cars.map(function(c) { return c.priceNum; })); var maxP = Math.max.apply(null, data.cars.map(function(c) { return c.priceNum; })); return React.createElement('div', { className: 'theme-dark', style: { background: 'transparent', padding: '8px', color: '#e0e0e0' } }, React.createElement('div', { className: 'row g-2 mb-3' }, React.createElement(StatCard, { label: 'Options Found', value: data.cars.length }), React.createElement(StatCard, { label: 'Avg Price', value: '$' + avgPrice.toLocaleString() }), React.createElement(StatCard, { label: 'Price Range', value: '$' + (minP/1000).toFixed(1) + 'k$' + (maxP/1000).toFixed(1) + 'k' })), React.createElement('div', { className: 'mb-2' }, React.createElement('span', { style: { color: '#8888aa', marginRight: '8px' } }, 'Sort:'), React.createElement('button', { className: 'btn btn-sm ' + (sortKey === 'price' ? 'btn-primary' : 'btn-secondary'), style: { marginRight: '6px' }, onClick: function() { setSortKey('price'); } }, 'Price'), React.createElement('button', { className: 'btn btn-sm ' + (sortKey === 'miles' ? 'btn-primary' : 'btn-secondary'), onClick: function() { setSortKey('miles'); } }, 'Miles')), React.createElement('table', { className: 'table table-dark table-hover table-sm' }, React.createElement('thead', null, React.createElement('tr', null, React.createElement('th', null, 'Model'), React.createElement('th', null, 'Price'), React.createElement('th', null, 'Miles'), React.createElement('th', null, 'MPG'))), React.createElement('tbody', null, cars.map(function(c, i) { return React.createElement('tr', { key: i }, React.createElement('td', null, c.model), React.createElement('td', { style: { color: '#7af7a2' } }, c.price), React.createElement('td', null, c.miles.toLocaleString()), React.createElement('td', null, c.mpg)); })))); } ReactDOM.createRoot(root).render(React.createElement(App));"
}
```
````
---
### 5. Decision Matrix (HTML + interactive weight sliders)
Interactive weight sliders recalculate scores in real time; the best option is highlighted green.
````
```visual
{
"title": "Car Decision Matrix",
"height": 400,
"data": {
"criteria": [
{ "name": "Price", "weight": 8 },
{ "name": "MPG", "weight": 7 },
{ "name": "Reliability", "weight": 9 },
{ "name": "Cargo", "weight": 5 },
{ "name": "Comfort", "weight": 6 }
],
"options": [
{ "name": "Prius 2015", "scores": [7, 10, 9, 6, 7] },
{ "name": "Civic Hybrid 2014", "scores": [9, 8, 8, 5, 6] },
{ "name": "Camry Hybrid 2013", "scores": [5, 7, 8, 8, 9] },
{ "name": "Fusion Hybrid 2014", "scores": [7, 8, 7, 7, 8] }
]
},
"code": "<style>.matrix-table{width:100%;border-collapse:collapse;font-size:0.85rem}.matrix-table th,.matrix-table td{padding:4px 8px;border:1px solid #2d3561;text-align:center}.matrix-table th{background:#1a1f2e;color:#8888aa}.score-cell{color:#c0c0d0}.best{color:#7af7a2;font-weight:bold}.weight-slider{width:80px;accent-color:#7aa2f7}</style><div style='background:transparent;color:#e0e0e0;padding:8px'><div style='margin-bottom:12px;display:flex;flex-wrap:wrap;gap:8px' id='sliders'></div><table class='matrix-table'><thead id='thead'></thead><tbody id='tbody'></tbody></table></div><script>var weights=data.criteria.map(function(c){return c.weight;});function render(){var scores=data.options.map(function(opt){return opt.scores.reduce(function(s,sc,i){return s+sc*weights[i];},0);});var best=Math.max.apply(null,scores);var thead=document.getElementById('thead');thead.innerHTML='<tr><th>Option</th>'+data.criteria.map(function(c){return'<th>'+c.name+'</th>';}).join('')+'<th>Total</th></tr>';var tbody=document.getElementById('tbody');tbody.innerHTML=data.options.map(function(opt,oi){var total=scores[oi];var isBest=total===best;return'<tr><td>'+(isBest?'<span class=\"best\">'+opt.name+' ★</span>':opt.name)+'</td>'+opt.scores.map(function(s){return'<td class=\"score-cell\">'+s+'</td>';}).join('')+'<td class=\"'+(isBest?'best':'score-cell')+'\">'+total+'</td></tr>';}).join('');}var sliderDiv=document.getElementById('sliders');data.criteria.forEach(function(c,i){var label=document.createElement('label');label.style.cssText='display:flex;flex-direction:column;align-items:center;font-size:0.75rem;color:#8888aa';var inp=document.createElement('input');inp.type='range';inp.min=0;inp.max=10;inp.value=c.weight;inp.className='weight-slider';inp.oninput=function(){weights[i]=+this.value;render();};label.appendChild(inp);label.appendChild(document.createTextNode(c.name));sliderDiv.appendChild(label);});render();</script>"
}
```
````
---
## Constraints
- **Self-contained** — no API fetches; pass all data through the `data` field or hardcode it in `code`
- **Dark theme always** — use Tabler `.theme-dark` class, transparent background, light text (`#e0e0e0`)
- **Responsive width** — use percentages and `max-width`, never fixed pixel widths for layout
- **Max height 800px** — use `mcp.resize(height)` to auto-size after content renders
- **No external fetches** — never use `fetch()`, `XMLHttpRequest`, or dynamic script loading in `code`
- **Click-to-explore** — chart bars, table rows, and map markers should call `mcp.sendMessage(...)` so the user can drill in
---
## When to Use Visuals vs A2UI
| Need | Choice |
|---|---|
| Toggle filters for quick filtering | A2UI (`filters` block) |
| Simple form (refine search params) | A2UI (`form` block) |
| Static table with fewer than 5 rows | A2UI (`table` block) |
| Card grid of items with links | A2UI (`cards` block) |
| Interactive charts (bar, line, pie) | `visual` block |
| sortable / filterable table with many rows | `visual` block |
| Comparisons with color-coded columns | `visual` block |
| Maps with markers and click interactions | `visual` block |
| Dashboards with multiple stat cards | `visual` block |
| Decision matrices with weight sliders | `visual` block |
+26
View File
@@ -0,0 +1,26 @@
# Prerequisites before running:
#
# 1. Build the frontend:
# cd frontend && npm run build
#
# 2. Set ANTHROPIC_API_KEY in your environment:
# export ANTHROPIC_API_KEY=your-key-here
#
# 3. Generate a bcrypt password hash for AUTH_PASS_HASH:
# python -c "import bcrypt; print(bcrypt.hashpw(b'YOUR_PASSWORD', bcrypt.gensalt()).decode())"
# export AUTH_PASS_HASH='$2b$12$...'
services:
workbench:
build: .
ports:
- "8080:8080" # Web UI
- "6080:6080" # noVNC browser view
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- AUTH_USER=${AUTH_USER:-admin@localhost}
- AUTH_PASS_HASH=${AUTH_PASS_HASH}
- DISPLAY=:99
volumes:
- ./artifacts:/app/artifacts # persist artifacts across restarts
restart: unless-stopped
+4 -1
View File
@@ -21,8 +21,11 @@ sleep 1
echo "[OK] noVNC on :6080" echo "[OK] noVNC on :6080"
# (4) Start amplifierd and wait for it to be healthy # (4) Start amplifierd and wait for it to be healthy
# amplifierd uses a 'serve' subcommand; --bundle requires NAME=URI format
cd /app cd /app
amplifierd --port 8410 --bundle /app/bundle/bundle.md & amplifierd serve --port 8410 \
--bundle "research-workbench=/app/bundle/bundle.md" \
--default-bundle research-workbench &
AMPLIFIERD_PID=$! AMPLIFIERD_PID=$!
echo "Waiting for amplifierd..." echo "Waiting for amplifierd..."
+233
View File
@@ -0,0 +1,233 @@
import { useState } from 'react'
import { IconFilter, IconTable, IconForms } from '@tabler/icons-react'
import type { A2UIBlock } from '../lib/types'
interface A2UIRendererProps {
block: A2UIBlock
onAction?: (action: string, data: Record<string, unknown>) => void
}
// ─── Filters ─────────────────────────────────────────────────────────────────
function FiltersRenderer({ block, onAction }: A2UIRendererProps & { block: Extract<A2UIBlock, { type: 'filters' }> }) {
const [activeLabels, setActiveLabels] = useState<Set<string>>(new Set())
function handleToggle(label: string) {
const isActive = activeLabels.has(label)
setActiveLabels(prev => {
const next = new Set(prev)
if (isActive) next.delete(label)
else next.add(label)
return next
})
onAction?.('filter_toggle', { label, active: !isActive })
}
return (
<div
className='tool-call-card'
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', alignItems: 'center' }}
>
<IconFilter size={16} color='#7aa2f7' style={{ marginRight: 4 }} />
{block.filters.map(option => {
const active = activeLabels.has(option.label)
return (
<button
key={option.value}
onClick={() => handleToggle(option.label)}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '12px',
border: '1px solid',
borderColor: active ? '#5a7aba' : '#3a3a5a',
background: active ? '#2a3a6a' : 'transparent',
color: active ? '#ffffff' : '#8888aa',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
{option.label}
</button>
)
})}
</div>
)
}
// ─── Table ───────────────────────────────────────────────────────────────────
function TableRenderer({ block }: { block: Extract<A2UIBlock, { type: 'table' }> }) {
return (
<div className='tool-call-card' style={{ overflowX: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
<IconTable size={16} color='#7aa2f7' />
<span style={{ color: '#8888aa', fontSize: '0.8rem' }}>Data Table</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
{block.headers.map((header, i) => (
<th
key={i}
style={{
textAlign: 'left',
padding: '0.3rem 0.5rem',
borderBottom: '1px solid #2a2a4a',
color: '#aaaacc',
fontWeight: 600,
}}
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
style={{
padding: '0.3rem 0.5rem',
borderBottom: '1px solid #1a1a2e',
color: '#c0c0d0',
}}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
// ─── Cards ───────────────────────────────────────────────────────────────────
function CardsRenderer({ block }: { block: Extract<A2UIBlock, { type: 'cards' }> }) {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '0.5rem',
margin: '0.5rem 0',
}}
>
{block.items.map((item, i) => (
<div
key={i}
className='tool-call-card'
style={{ cursor: item.url ? 'pointer' : undefined }}
onClick={item.url ? () => window.open(item.url, '_blank') : undefined}
>
<div style={{ fontWeight: 600, color: '#e0e0e0' }}>{item.title}</div>
{item.price != null && (
<div style={{ color: '#7af7a2', fontWeight: 600, fontSize: '0.9rem' }}>{item.price}</div>
)}
{item.description && (
<div style={{ color: '#8888aa', fontSize: '0.75rem', marginTop: 4 }}>{item.description}</div>
)}
</div>
))}
</div>
)
}
// ─── Form ────────────────────────────────────────────────────────────────────
function FormRenderer({
block,
onAction,
}: {
block: Extract<A2UIBlock, { type: 'form' }>
onAction?: (action: string, data: Record<string, unknown>) => void
}) {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const data: Record<string, unknown> = {}
formData.forEach((value, key) => {
data[key] = value
})
onAction?.('form_submit', data)
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.3rem',
background: '#1e2a4a',
border: '1px solid #3a3a5a',
borderRadius: '4px',
color: '#e0e0e0',
fontSize: '0.8rem',
boxSizing: 'border-box',
}
return (
<div className='tool-call-card'>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
<IconForms size={16} color='#7aa2f7' />
<span style={{ color: '#8888aa', fontSize: '0.8rem' }}>Input Form</span>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{block.fields.map(field => (
<div key={field.name}>
<label style={{ fontSize: '0.75rem', color: '#8888aa', display: 'block' }}>
{field.label || field.name}
</label>
{field.type === 'select' ? (
<select name={field.name} style={inputStyle}>
{field.options?.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<input name={field.name} type={field.type} style={inputStyle} />
)}
</div>
))}
<button
type='submit'
style={{
padding: '0.4rem 0.8rem',
background: '#3a5aba',
color: '#ffffff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.8rem',
marginTop: '0.3rem',
alignSelf: 'flex-start',
}}
>
{block.submitLabel || 'Submit'}
</button>
</form>
</div>
)
}
// ─── Main Renderer ───────────────────────────────────────────────────────────
export function A2UIRenderer({ block, onAction }: A2UIRendererProps) {
switch (block.type) {
case 'filters':
return <FiltersRenderer block={block} onAction={onAction} />
case 'table':
return <TableRenderer block={block} />
case 'cards':
return <CardsRenderer block={block} />
case 'form':
return <FormRenderer block={block} onAction={onAction} />
default:
return null
}
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import { IconFileText, IconRefresh } from '@tabler/icons-react'
import { useArtifacts } from '../hooks/useArtifacts'
export interface ArtifactsPanelProps {
sessionId: string | null
}
export function ArtifactsPanel({ sessionId }: ArtifactsPanelProps) {
const { artifacts, activeArtifact, refresh, loadArtifact } = useArtifacts(sessionId)
useEffect(() => {
if (sessionId) {
refresh()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
if (!sessionId) {
return (
<div
style={{
padding: '2rem',
color: '#6a6a8a',
textAlign: 'center',
}}
>
Select a session to view artifacts
</div>
)
}
const count = artifacts.length
const countLabel = count === 1 ? '1 artifact' : `${count} artifacts`
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header bar */}
<div
style={{
padding: '0.5rem 0.75rem',
borderBottom: '1px solid #2a2a4a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontSize: '0.8rem', color: '#8888aa' }}>{countLabel}</span>
<button
title="Refresh"
onClick={refresh}
style={{
background: 'none',
border: 'none',
color: '#7aa2f7',
cursor: 'pointer',
}}
>
<IconRefresh size={16} />
</button>
</div>
{/* Tab strip — only when there are artifacts */}
{artifacts.length > 0 && (
<div
style={{
display: 'flex',
gap: '2px',
padding: '0.3rem 0.5rem',
overflowX: 'auto',
borderBottom: '1px solid #2a2a4a',
}}
>
{artifacts.map((artifact) => {
const isActive = activeArtifact?.name === artifact.name
return (
<button
key={artifact.name}
onClick={() => loadArtifact(artifact.name)}
style={{
background: isActive ? '#2a3a5a' : 'transparent',
color: isActive ? '#ffffff' : '#8888aa',
padding: '0.3rem 0.6rem',
borderRadius: '4px',
fontSize: '0.75rem',
whiteSpace: 'nowrap',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
}}
>
<IconFileText size={14} />
{artifact.name}
</button>
)
})}
</div>
)}
{/* Content area */}
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem' }}>
{activeArtifact ? (
<div className="chat-message assistant">
<ReactMarkdown>{activeArtifact.content}</ReactMarkdown>
</div>
) : artifacts.length > 0 ? (
<div
style={{ color: '#6a6a8a', textAlign: 'center', paddingTop: '2rem' }}
>
Click an artifact tab to view it
</div>
) : (
<div
style={{ color: '#6a6a8a', textAlign: 'center', paddingTop: '2rem' }}
>
No artifacts yet. The researcher will create them during research.
</div>
)}
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
// BrowserPanel renders a noVNC iframe for viewing the live Playwright browser.
//
// URL detection strategy:
// - Production (cloudflared): When hostname is "research.ampbox.io", use the
// dedicated noVNC HTTPS endpoint at vnc.ampbox.io (routed via cloudflared tunnel).
// - Local dev (Docker): For any other hostname (e.g. localhost, 192.168.x.x),
// connect directly to the noVNC container on port 6080 over plain HTTP.
export interface BrowserPanelProps {
sessionId: string | null
}
function getVncUrl(): string {
const hostname = window.location.hostname
if (hostname === 'research.ampbox.io') {
return 'https://vnc.ampbox.io/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
}
return `http://${hostname}:6080/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2`
}
export function BrowserPanel({ sessionId }: BrowserPanelProps) {
if (!sessionId) {
return (
<div
style={{
padding: '2rem',
color: '#6a6a8a',
textAlign: 'center',
}}
>
Select a session to view the browser
</div>
)
}
return (
<iframe
src={getVncUrl()}
title="Browser - noVNC"
style={{
width: '100%',
height: '100%',
border: 'none',
background: '#0a0a1a',
}}
allow="clipboard-read; clipboard-write"
/>
)
}
+161
View File
@@ -4,6 +4,8 @@ import ReactMarkdown from 'react-markdown'
import { IconSend } from '@tabler/icons-react' import { IconSend } from '@tabler/icons-react'
import { ToolCallCard } from './ToolCallCard' import { ToolCallCard } from './ToolCallCard'
import { MCPAppRenderer } from './MCPAppRenderer' import { MCPAppRenderer } from './MCPAppRenderer'
import { A2UIRenderer } from './A2UIRenderer'
import { parseA2UIBlocks } from '../lib/a2ui'
import type { ChatMessage } from '../hooks/useChat' import type { ChatMessage } from '../hooks/useChat'
interface ChatPanelProps { interface ChatPanelProps {
@@ -13,6 +15,147 @@ interface ChatPanelProps {
sessionId: string | null sessionId: string | null
} }
// ─── Visual Block Types ────────────────────────────────────────────────────────
interface VisualBlockPayload {
title?: string
code: string
data?: Record<string, unknown>
height?: number
}
function parseVisualBlocks(content: string): VisualBlockPayload[] {
const blocks: VisualBlockPayload[] = []
const regex = /```visual\s*\n([\s\S]*?)```/g
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1]) as VisualBlockPayload
if (parsed && typeof parsed.code === 'string') {
blocks.push(parsed)
}
} catch {
// Silently skip invalid JSON
}
}
return blocks
}
// ─── VisualBlock Component ─────────────────────────────────────────────────────
const controlBtnStyle: React.CSSProperties = {
background: 'none',
border: '1px solid #3a3a5a',
color: '#8888aa',
borderRadius: '4px',
padding: '0.15rem 0.4rem',
fontSize: '0.75rem',
cursor: 'pointer',
}
function VisualBlock({
payload,
onSend,
}: {
payload: VisualBlockPayload
onSend: (msg: string) => void
}) {
const [showSource, setShowSource] = useState(false)
function handlePopOut() {
const w = window.open(
'',
'_blank',
`width=900,height=${(payload.height ?? 300) + 120}`
)
if (w) {
fetch('/api/apps/runtime')
.then(r => r.text())
.then(html => {
w.document.open()
w.document.write(html)
w.document.close()
// Give the iframe time to initialize, then send the render message
setTimeout(() => {
w.postMessage(
{
jsonrpc: '2.0',
method: 'render',
params: { code: payload.code, data: payload.data ?? {} },
},
'*'
)
}, 500)
})
.catch(() => {
w.close()
})
}
}
return (
<div className='visual-block' style={{ margin: '0.5rem 0' }}>
{/* Header bar */}
<div
style={{
padding: '0.3rem 0.6rem',
borderBottom: '1px solid #2a2a4a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: '#12192e',
borderRadius: '4px 4px 0 0',
border: '1px solid #2a2a4a',
}}
>
<span style={{ color: '#7aa2f7', fontSize: '0.8rem' }}>
{payload.title ?? 'Visual'}
</span>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<button
onClick={() => setShowSource(s => !s)}
style={controlBtnStyle}
>
{showSource ? 'Hide Source' : 'View Source'}
</button>
<button onClick={handlePopOut} style={controlBtnStyle}>
Pop Out
</button>
</div>
</div>
{/* Source viewer */}
{showSource && (
<pre
style={{
background: '#0a0a1a',
padding: '0.5rem',
fontSize: '0.75rem',
color: '#c0c0d0',
overflowX: 'auto',
margin: 0,
borderLeft: '1px solid #2a2a4a',
borderRight: '1px solid #2a2a4a',
}}
>
{payload.code}
</pre>
)}
{/* Runtime iframe via MCPAppRenderer */}
<MCPAppRenderer
resourceUri='app://runtime'
data={{ ...payload, runtime: 'visual-artifact' }}
onSendMessage={onSend}
/>
</div>
)
}
// ─── ChatPanel ─────────────────────────────────────────────────────────────────
export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPanelProps) { export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPanelProps) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -62,6 +205,24 @@ export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPane
{message.role === 'assistant' ? ( {message.role === 'assistant' ? (
<div> <div>
<ReactMarkdown>{message.content}</ReactMarkdown> <ReactMarkdown>{message.content}</ReactMarkdown>
{/* A2UI interactive blocks */}
{parseA2UIBlocks(message.content).map((block, i) => (
<A2UIRenderer
key={i}
block={block}
onAction={(action, data) =>
onSend(`[${action}] ${JSON.stringify(data)}`)
}
/>
))}
{/* Visual artifact blocks */}
{parseVisualBlocks(message.content).map((payload, i) => (
<VisualBlock key={i} payload={payload} onSend={onSend} />
))}
{/* Tool call cards / MCP app renderers */}
{message.toolCalls?.map(tc => {message.toolCalls?.map(tc =>
tc._meta?.ui?.resourceUri ? ( tc._meta?.ui?.resourceUri ? (
<MCPAppRenderer <MCPAppRenderer
@@ -62,10 +62,33 @@ export function MCPAppRenderer({
switch (msg.method) { switch (msg.method) {
case 'ui/initialize': case 'ui/initialize':
// Visual artifacts receive a `render` dispatch; all other apps get `tool/input`
if (data.runtime === 'visual-artifact') {
iframeRef.current.contentWindow.postMessage(
{
jsonrpc: '2.0',
method: 'render',
params: {
code: (data.code as string) ?? '',
data: (data.data as Record<string, unknown>) ?? {},
},
},
'*'
)
} else {
iframeRef.current.contentWindow.postMessage( iframeRef.current.contentWindow.postMessage(
{ jsonrpc: '2.0', method: 'tool/input', params: data }, { jsonrpc: '2.0', method: 'tool/input', params: data },
'*' '*'
) )
}
break
case 'render':
// Forward render requests from parent context to iframe
iframeRef.current.contentWindow.postMessage(
{ jsonrpc: '2.0', method: 'render', params: msg.params ?? {} },
'*'
)
break break
case 'tools/call': case 'tools/call':
+7 -11
View File
@@ -1,4 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { BrowserPanel } from './BrowserPanel'
import { ArtifactsPanel } from './ArtifactsPanel'
interface RightPanelProps { interface RightPanelProps {
sessionId: string | null sessionId: string | null
@@ -7,16 +9,6 @@ interface RightPanelProps {
export function RightPanel({ sessionId }: RightPanelProps) { export function RightPanel({ sessionId }: RightPanelProps) {
const [activeTab, setActiveTab] = useState<'browser' | 'artifacts'>('browser') const [activeTab, setActiveTab] = useState<'browser' | 'artifacts'>('browser')
function renderContent() {
if (!sessionId) {
return 'Select a session to view the browser/artifacts'
}
if (activeTab === 'browser') {
return 'Browser panel (noVNC) — implemented in Phase 3'
}
return 'Artifacts panel — implemented in Phase 3'
}
return ( return (
<div className='right-panel'> <div className='right-panel'>
<div className='right-panel-tabs'> <div className='right-panel-tabs'>
@@ -34,7 +26,11 @@ export function RightPanel({ sessionId }: RightPanelProps) {
</button> </button>
</div> </div>
<div className='right-panel-content'> <div className='right-panel-content'>
{renderContent()} {activeTab === 'browser' ? (
<BrowserPanel sessionId={sessionId} />
) : (
<ArtifactsPanel sessionId={sessionId} />
)}
</div> </div>
</div> </div>
) )
+1 -1
View File
@@ -95,7 +95,7 @@ export function useChat(sessionId: string | null) {
break break
} }
case 'TEXT_MESSAGE_END': { case 'TEXT_MESSAGE_END': {
// no-op assistantId = null
break break
} }
case 'TOOL_CALL_START': { case 'TOOL_CALL_START': {
+25
View File
@@ -0,0 +1,25 @@
import type { A2UIBlock } from './types'
/**
* Parses `a2ui` fenced code blocks out of a markdown-like content string.
* Each block must contain a valid JSON object with a `type` field.
* Invalid blocks are silently skipped.
*/
export function parseA2UIBlocks(content: string): A2UIBlock[] {
const blocks: A2UIBlock[] = []
const regex = /```a2ui\s*\n([\s\S]*?)```/g
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1])
if (parsed && parsed.type) {
blocks.push(parsed as A2UIBlock)
}
} catch {
// Silently skip invalid JSON
}
}
return blocks
}
+380
View File
@@ -0,0 +1,380 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { A2UIRenderer } from '../components/A2UIRenderer'
import { parseA2UIBlocks } from '../lib/a2ui'
import type { A2UIBlock } from '../lib/types'
// Mock @tabler/icons-react so icon SVGs don't cause jsdom issues
vi.mock('@tabler/icons-react', () => ({
IconFilter: ({ size, color }: { size?: number; color?: string }) => (
<span data-testid='icon-filter' data-size={size} data-color={color} />
),
IconTable: ({ size, color }: { size?: number; color?: string }) => (
<span data-testid='icon-table' data-size={size} data-color={color} />
),
IconCards: ({ size, color }: { size?: number; color?: string }) => (
<span data-testid='icon-cards' data-size={size} data-color={color} />
),
IconForms: ({ size, color }: { size?: number; color?: string }) => (
<span data-testid='icon-forms' data-size={size} data-color={color} />
),
}))
// ─── parseA2UIBlocks ─────────────────────────────────────────────────────────
describe('parseA2UIBlocks', () => {
it('extracts a single a2ui block from content', () => {
const content = '```a2ui\n{"type":"filters","filters":[]}\n```'
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(1)
expect(blocks[0].type).toBe('filters')
})
it('extracts multiple a2ui blocks from content', () => {
const content = [
'```a2ui\n{"type":"filters","filters":[]}\n```',
' some text ',
'```a2ui\n{"type":"table","headers":[],"rows":[]}\n```',
].join('')
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(2)
expect(blocks[0].type).toBe('filters')
expect(blocks[1].type).toBe('table')
})
it('silently skips invalid JSON', () => {
const content = '```a2ui\nnot valid json\n```'
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(0)
})
it('skips blocks without a type field', () => {
const content = '```a2ui\n{"data":"no type"}\n```'
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(0)
})
it('returns empty array when no a2ui blocks present', () => {
const content = 'plain text with ```js\nsome code\n```'
const blocks = parseA2UIBlocks(content)
expect(blocks).toHaveLength(0)
})
it('returns the parsed block as A2UIBlock with all fields', () => {
const json = JSON.stringify({
type: 'table',
headers: ['Name', 'Price'],
rows: [['Apple', '$1']],
})
const content = `\`\`\`a2ui\n${json}\n\`\`\``
const blocks = parseA2UIBlocks(content)
const block = blocks[0] as import('../lib/types').A2UITableBlock
expect(block.headers).toEqual(['Name', 'Price'])
expect(block.rows).toEqual([['Apple', '$1']])
})
})
// ─── A2UIRenderer — filters ─────────────────────────────────────────────────
describe('A2UIRenderer — filters', () => {
const filtersBlock: A2UIBlock = {
type: 'filters',
filters: [
{ label: 'In Stock', value: 'in_stock' },
{ label: 'On Sale', value: 'on_sale' },
],
}
it('renders inside a tool-call-card container', () => {
const { container } = render(<A2UIRenderer block={filtersBlock} />)
expect(container.querySelector('.tool-call-card')).toBeTruthy()
})
it('renders the IconFilter icon', () => {
render(<A2UIRenderer block={filtersBlock} />)
expect(screen.getByTestId('icon-filter')).toBeTruthy()
})
it('renders a button for each filter option', () => {
const { container } = render(<A2UIRenderer block={filtersBlock} />)
const buttons = container.querySelectorAll('button')
expect(buttons).toHaveLength(2)
})
it('renders filter label text in buttons', () => {
render(<A2UIRenderer block={filtersBlock} />)
expect(screen.getByText('In Stock')).toBeTruthy()
expect(screen.getByText('On Sale')).toBeTruthy()
})
it('calls onAction with filter_toggle and label when button clicked', () => {
const onAction = vi.fn()
render(<A2UIRenderer block={filtersBlock} onAction={onAction} />)
fireEvent.click(screen.getByText('In Stock'))
expect(onAction).toHaveBeenCalledWith('filter_toggle', {
label: 'In Stock',
active: true,
})
})
it('toggles active state on second click (active: false)', () => {
const onAction = vi.fn()
render(<A2UIRenderer block={filtersBlock} onAction={onAction} />)
fireEvent.click(screen.getByText('In Stock'))
fireEvent.click(screen.getByText('In Stock'))
expect(onAction).toHaveBeenLastCalledWith('filter_toggle', {
label: 'In Stock',
active: false,
})
})
it('does not throw when onAction is not provided', () => {
render(<A2UIRenderer block={filtersBlock} />)
expect(() => fireEvent.click(screen.getByText('In Stock'))).not.toThrow()
})
})
// ─── A2UIRenderer — table ───────────────────────────────────────────────────
describe('A2UIRenderer — table', () => {
const tableBlock: A2UIBlock = {
type: 'table',
headers: ['Name', 'Price', 'Stock'],
rows: [
['Apple', '$1.00', '50'],
['Banana', '$0.50', '100'],
],
}
it('renders inside a tool-call-card container', () => {
const { container } = render(<A2UIRenderer block={tableBlock} />)
expect(container.querySelector('.tool-call-card')).toBeTruthy()
})
it('renders the IconTable icon', () => {
render(<A2UIRenderer block={tableBlock} />)
expect(screen.getByTestId('icon-table')).toBeTruthy()
})
it('renders "Data Table" header label', () => {
render(<A2UIRenderer block={tableBlock} />)
expect(screen.getByText('Data Table')).toBeTruthy()
})
it('renders a <table> element', () => {
const { container } = render(<A2UIRenderer block={tableBlock} />)
expect(container.querySelector('table')).toBeTruthy()
})
it('renders all column headers', () => {
render(<A2UIRenderer block={tableBlock} />)
expect(screen.getByText('Name')).toBeTruthy()
expect(screen.getByText('Price')).toBeTruthy()
expect(screen.getByText('Stock')).toBeTruthy()
})
it('renders all row data', () => {
render(<A2UIRenderer block={tableBlock} />)
expect(screen.getByText('Apple')).toBeTruthy()
expect(screen.getByText('$1.00')).toBeTruthy()
expect(screen.getByText('Banana')).toBeTruthy()
})
it('renders correct number of th elements', () => {
const { container } = render(<A2UIRenderer block={tableBlock} />)
expect(container.querySelectorAll('th')).toHaveLength(3)
})
it('renders correct number of td elements', () => {
const { container } = render(<A2UIRenderer block={tableBlock} />)
// 2 rows × 3 cols = 6 td
expect(container.querySelectorAll('td')).toHaveLength(6)
})
})
// ─── A2UIRenderer — cards ───────────────────────────────────────────────────
describe('A2UIRenderer — cards', () => {
const cardsBlock: A2UIBlock = {
type: 'cards',
items: [
{
title: 'Widget Pro',
description: 'Best widget ever',
price: '$29.99',
url: 'https://example.com/widget',
},
{ title: 'Gadget Basic', description: 'Simple gadget' },
],
}
it('renders a grid container with multiple tool-call-card elements', () => {
const { container } = render(<A2UIRenderer block={cardsBlock} />)
const cards = container.querySelectorAll('.tool-call-card')
expect(cards.length).toBeGreaterThanOrEqual(2)
})
it('renders each card title', () => {
render(<A2UIRenderer block={cardsBlock} />)
expect(screen.getByText('Widget Pro')).toBeTruthy()
expect(screen.getByText('Gadget Basic')).toBeTruthy()
})
it('renders price when present', () => {
render(<A2UIRenderer block={cardsBlock} />)
expect(screen.getByText('$29.99')).toBeTruthy()
})
it('renders description when present', () => {
render(<A2UIRenderer block={cardsBlock} />)
expect(screen.getByText('Best widget ever')).toBeTruthy()
})
it('opens url in new tab on click when url is present', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
render(<A2UIRenderer block={cardsBlock} />)
fireEvent.click(screen.getByText('Widget Pro'))
expect(openSpy).toHaveBeenCalledWith('https://example.com/widget', '_blank')
openSpy.mockRestore()
})
it('does not set cursor pointer on cards without url', () => {
const { container } = render(<A2UIRenderer block={cardsBlock} />)
// Find the card for "Gadget Basic" — the second tool-call-card
const cards = container.querySelectorAll('.tool-call-card')
const gadgetCard = Array.from(cards).find(
card => card.textContent?.includes('Gadget Basic')
)
expect(gadgetCard).toBeTruthy()
expect((gadgetCard as HTMLElement).style.cursor).not.toBe('pointer')
})
})
// ─── A2UIRenderer — form ────────────────────────────────────────────────────
describe('A2UIRenderer — form', () => {
const formBlock: A2UIBlock = {
type: 'form',
submitLabel: 'Search',
fields: [
{ name: 'query', type: 'text', label: 'Search Query' },
{ name: 'max_price', type: 'number', label: 'Max Price' },
{
name: 'category',
type: 'select',
label: 'Category',
options: [
{ label: 'Electronics', value: 'electronics' },
{ label: 'Books', value: 'books' },
],
},
],
}
it('renders inside a tool-call-card container', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
expect(container.querySelector('.tool-call-card')).toBeTruthy()
})
it('renders the IconForms icon', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByTestId('icon-forms')).toBeTruthy()
})
it('renders "Input Form" header label', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByText('Input Form')).toBeTruthy()
})
it('renders a <form> element', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
expect(container.querySelector('form')).toBeTruthy()
})
it('renders labels for each field', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByText('Search Query')).toBeTruthy()
expect(screen.getByText('Max Price')).toBeTruthy()
expect(screen.getByText('Category')).toBeTruthy()
})
it('renders text input for text field type', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
const textInput = container.querySelector('input[type="text"]')
expect(textInput).toBeTruthy()
})
it('renders number input for number field type', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
const numInput = container.querySelector('input[type="number"]')
expect(numInput).toBeTruthy()
})
it('renders a <select> for select field type', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
expect(container.querySelector('select')).toBeTruthy()
})
it('renders select options', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByText('Electronics')).toBeTruthy()
expect(screen.getByText('Books')).toBeTruthy()
})
it('renders submit button with submitLabel text', () => {
render(<A2UIRenderer block={formBlock} />)
expect(screen.getByText('Search')).toBeTruthy()
})
it('uses "Submit" as default submit label when submitLabel absent', () => {
const blockWithoutLabel: A2UIBlock = {
type: 'form',
fields: [{ name: 'q', type: 'text' }],
submitLabel: '',
}
render(<A2UIRenderer block={blockWithoutLabel} />)
expect(screen.getByText('Submit')).toBeTruthy()
})
it('uses field.name as label when field.label is absent', () => {
const blockNoLabel: A2UIBlock = {
type: 'form',
fields: [{ name: 'my_field', type: 'text' }],
submitLabel: 'Go',
}
render(<A2UIRenderer block={blockNoLabel} />)
expect(screen.getByText('my_field')).toBeTruthy()
})
it('calls onAction with form_submit and form data on submit', () => {
const onAction = vi.fn()
const { container } = render(
<A2UIRenderer block={formBlock} onAction={onAction} />
)
const form = container.querySelector('form')!
fireEvent.submit(form)
expect(onAction).toHaveBeenCalledWith(
'form_submit',
expect.any(Object)
)
})
it('prevents default form submission', () => {
const { container } = render(<A2UIRenderer block={formBlock} />)
const form = container.querySelector('form')!
const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
form.dispatchEvent(submitEvent)
expect(submitEvent.defaultPrevented).toBe(true)
})
})
// ─── A2UIRenderer — default / unknown type ──────────────────────────────────
describe('A2UIRenderer — default', () => {
it('returns null for unknown block type', () => {
// Cast to bypass TypeScript — simulates runtime unknown type
const unknown = { type: 'unknown' } as unknown as A2UIBlock
const { container } = render(<A2UIRenderer block={unknown} />)
expect(container.firstChild).toBeNull()
})
})
+283
View File
@@ -0,0 +1,283 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ArtifactContent, Artifact } from '../lib/types'
// Must be hoisted before imports
vi.mock('react-markdown', () => ({
default: ({ children }: { children: string }) => <div>{children}</div>,
}))
vi.mock('../hooks/useArtifacts')
import { ArtifactsPanel } from '../components/ArtifactsPanel'
import { useArtifacts } from '../hooks/useArtifacts'
const mockUseArtifacts = vi.mocked(useArtifacts)
const mockRefresh = vi.fn()
const mockLoadArtifact = vi.fn()
function makeHookReturn(
artifacts: Artifact[] = [],
activeArtifact: ArtifactContent | null = null
) {
return {
artifacts,
activeArtifact,
refresh: mockRefresh,
loadArtifact: mockLoadArtifact,
}
}
describe('ArtifactsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseArtifacts.mockReturnValue(makeHookReturn())
})
// ─── No session placeholder ───────────────────────────────────────────
describe('no-session placeholder', () => {
it('renders placeholder text when sessionId is null', () => {
render(<ArtifactsPanel sessionId={null} />)
expect(screen.getByText('Select a session to view artifacts')).toBeTruthy()
})
it('placeholder has padding 2rem', () => {
const { container } = render(<ArtifactsPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.padding).toBe('2rem')
})
it('placeholder has color #6a6a8a (rgb(106, 106, 138))', () => {
const { container } = render(<ArtifactsPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.color).toBe('rgb(106, 106, 138)')
})
it('placeholder is text-centered', () => {
const { container } = render(<ArtifactsPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.textAlign).toBe('center')
})
it('does not call refresh when sessionId is null', () => {
render(<ArtifactsPanel sessionId={null} />)
expect(mockRefresh).not.toHaveBeenCalled()
})
})
// ─── With session — useEffect ─────────────────────────────────────────
describe('with sessionId — useEffect', () => {
it('calls refresh when sessionId is set', async () => {
await act(async () => {
render(<ArtifactsPanel sessionId="sess-1" />)
})
expect(mockRefresh).toHaveBeenCalledTimes(1)
})
it('does not call refresh when sessionId is null', async () => {
await act(async () => {
render(<ArtifactsPanel sessionId={null} />)
})
expect(mockRefresh).not.toHaveBeenCalled()
})
})
// ─── Header bar ───────────────────────────────────────────────────────
describe('header bar', () => {
it('shows "0 artifacts" when artifacts is empty', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('0 artifacts')).toBeTruthy()
})
it('shows "1 artifact" (singular) when there is one artifact', () => {
mockUseArtifacts.mockReturnValue(
makeHookReturn([{ name: 'report.md', session_id: 'sess-1', size: 100 }])
)
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('1 artifact')).toBeTruthy()
})
it('shows "2 artifacts" (plural) when there are two artifacts', () => {
mockUseArtifacts.mockReturnValue(
makeHookReturn([
{ name: 'report.md', session_id: 'sess-1', size: 100 },
{ name: 'summary.md', session_id: 'sess-1', size: 200 },
])
)
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('2 artifacts')).toBeTruthy()
})
it('renders Refresh button with title "Refresh"', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByTitle('Refresh')).toBeTruthy()
})
it('Refresh button has color #7aa2f7 (rgb(122, 162, 247))', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
const btn = screen.getByTitle('Refresh') as HTMLButtonElement
expect(btn.style.color).toBe('rgb(122, 162, 247)')
})
it('Refresh button has no background', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
const btn = screen.getByTitle('Refresh') as HTMLButtonElement
// background: 'none' normalized as empty string or 'none'
expect(
btn.style.background === 'none' || btn.style.background === ''
).toBe(true)
})
it('clicking Refresh button calls refresh()', async () => {
render(<ArtifactsPanel sessionId="sess-1" />)
await userEvent.click(screen.getByTitle('Refresh'))
// refresh is also called on mount, so check at least once from click
expect(mockRefresh).toHaveBeenCalled()
})
})
// ─── No artifacts empty state ─────────────────────────────────────────
describe('no artifacts empty state', () => {
it('shows "No artifacts yet" message when artifacts array is empty', () => {
render(<ArtifactsPanel sessionId="sess-1" />)
expect(
screen.getByText(
'No artifacts yet. The researcher will create them during research.'
)
).toBeTruthy()
})
it('does not render tab strip when there are no artifacts', () => {
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
// No buttons other than Refresh should be present
const buttons = container.querySelectorAll('button')
expect(buttons.length).toBe(1) // only the Refresh button
})
})
// ─── Tab strip ────────────────────────────────────────────────────────
describe('tab strip', () => {
const artifacts: Artifact[] = [
{ name: 'report.md', session_id: 'sess-1', size: 100 },
{ name: 'summary.md', session_id: 'sess-1', size: 200 },
]
it('renders a tab button for each artifact', () => {
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts))
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
const buttons = container.querySelectorAll('button')
// Refresh + 2 artifact tabs
expect(buttons.length).toBe(3)
})
it('each tab shows the artifact name', () => {
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts))
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('report.md')).toBeTruthy()
expect(screen.getByText('summary.md')).toBeTruthy()
})
it('clicking a tab calls loadArtifact with the artifact name', async () => {
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts))
render(<ArtifactsPanel sessionId="sess-1" />)
await userEvent.click(screen.getByText('report.md'))
expect(mockLoadArtifact).toHaveBeenCalledWith('report.md')
})
it('active tab has background #2a3a5a and color #ffffff', () => {
const activeArtifact: ArtifactContent = {
name: 'report.md',
session_id: 'sess-1',
content: '# Report',
}
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts, activeArtifact))
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
const buttons = container.querySelectorAll('button')
// buttons[0] is Refresh, buttons[1] is first artifact tab
const activeTab = buttons[1] as HTMLElement
expect(activeTab.style.background).toBe('rgb(42, 58, 90)')
expect(activeTab.style.color).toBe('rgb(255, 255, 255)')
})
it('inactive tab has transparent background and color #8888aa', () => {
const activeArtifact: ArtifactContent = {
name: 'report.md',
session_id: 'sess-1',
content: '# Report',
}
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts, activeArtifact))
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
const buttons = container.querySelectorAll('button')
// buttons[2] is the second (inactive) artifact tab
const inactiveTab = buttons[2] as HTMLElement
expect(inactiveTab.style.color).toBe('rgb(136, 136, 170)')
})
it('shows "Click an artifact tab to view it" when artifacts exist but none selected', () => {
mockUseArtifacts.mockReturnValue(makeHookReturn(artifacts, null))
render(<ArtifactsPanel sessionId="sess-1" />)
expect(screen.getByText('Click an artifact tab to view it')).toBeTruthy()
})
})
// ─── Content area with active artifact ───────────────────────────────
describe('content area — active artifact', () => {
it('renders the artifact content inside ReactMarkdown', () => {
const activeArtifact: ArtifactContent = {
name: 'report.md',
session_id: 'sess-1',
content: '# Research Report\n\nFindings here.',
}
mockUseArtifacts.mockReturnValue(
makeHookReturn(
[{ name: 'report.md', session_id: 'sess-1', size: 100 }],
activeArtifact
)
)
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
// The mock renders children in a div; check text content directly to handle newlines
const contentWrapper = container.querySelector('.chat-message.assistant') as HTMLElement
expect(contentWrapper).toBeTruthy()
expect(contentWrapper.textContent).toContain('# Research Report')
expect(contentWrapper.textContent).toContain('Findings here.')
})
it('renders content inside a div with class "chat-message assistant"', () => {
const activeArtifact: ArtifactContent = {
name: 'report.md',
session_id: 'sess-1',
content: '# Report',
}
mockUseArtifacts.mockReturnValue(
makeHookReturn(
[{ name: 'report.md', session_id: 'sess-1', size: 100 }],
activeArtifact
)
)
const { container } = render(<ArtifactsPanel sessionId="sess-1" />)
expect(container.querySelector('.chat-message.assistant')).toBeTruthy()
})
})
// ─── useArtifacts integration ─────────────────────────────────────────
describe('useArtifacts integration', () => {
it('passes sessionId to useArtifacts', () => {
render(<ArtifactsPanel sessionId="sess-42" />)
expect(mockUseArtifacts).toHaveBeenCalledWith('sess-42')
})
it('passes null to useArtifacts when sessionId is null', () => {
render(<ArtifactsPanel sessionId={null} />)
expect(mockUseArtifacts).toHaveBeenCalledWith(null)
})
})
})
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserPanel } from '../components/BrowserPanel'
describe('BrowserPanel', () => {
const originalLocation = window.location
beforeEach(() => {
// Reset location before each test
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { hostname: 'localhost' },
})
})
afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: originalLocation,
})
})
describe('placeholder (no sessionId)', () => {
it('renders placeholder text when sessionId is null', () => {
render(<BrowserPanel sessionId={null} />)
expect(screen.getByText('Select a session to view the browser')).toBeTruthy()
})
it('does not render an iframe when sessionId is null', () => {
const { container } = render(<BrowserPanel sessionId={null} />)
expect(container.querySelector('iframe')).toBeNull()
})
it('placeholder div has padding 2rem', () => {
const { container } = render(<BrowserPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.padding).toBe('2rem')
})
it('placeholder div text has color #6a6a8a', () => {
const { container } = render(<BrowserPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
// jsdom normalizes hex colors to rgb() — #6a6a8a = rgb(106, 106, 138)
expect(div.style.color).toBe('rgb(106, 106, 138)')
})
it('placeholder div text is centered', () => {
const { container } = render(<BrowserPanel sessionId={null} />)
const div = container.firstElementChild as HTMLElement
expect(div.style.textAlign).toBe('center')
})
})
describe('iframe (with sessionId)', () => {
it('renders an iframe when sessionId is provided', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
expect(container.querySelector('iframe')).toBeTruthy()
})
it('does not render placeholder text when sessionId is provided', () => {
render(<BrowserPanel sessionId="session-abc" />)
expect(screen.queryByText('Select a session to view the browser')).toBeNull()
})
it('iframe has title "Browser - noVNC"', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.title).toBe('Browser - noVNC')
})
it('iframe has width 100%', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.style.width).toBe('100%')
})
it('iframe has height 100%', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.style.height).toBe('100%')
})
it('iframe has no border', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
// jsdom expands border shorthand — check borderStyle which carries the 'none' value
expect(iframe.style.borderStyle).toBe('none')
})
it('iframe has dark background color #0a0a1a', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
// jsdom normalizes hex colors to rgb() — #0a0a1a = rgb(10, 10, 26)
expect(iframe.style.background).toBe('rgb(10, 10, 26)')
})
it('iframe has clipboard allow attribute', () => {
const { container } = render(<BrowserPanel sessionId="session-abc" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.getAttribute('allow')).toBe('clipboard-read; clipboard-write')
})
})
describe('getVncUrl() — URL detection', () => {
it('returns cloudflared HTTPS URL when hostname is research.ampbox.io', () => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { hostname: 'research.ampbox.io' },
})
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toBe(
'https://vnc.ampbox.io/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
)
})
it('returns localhost http URL with port 6080 when hostname is localhost', () => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { hostname: 'localhost' },
})
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toBe(
'http://localhost:6080/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
)
})
it('returns http URL with other hostname when not research.ampbox.io', () => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { hostname: '192.168.1.100' },
})
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toBe(
'http://192.168.1.100:6080/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
)
})
it('URL includes autoconnect=true query param', () => {
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toContain('autoconnect=true')
})
it('URL includes resize=remote query param', () => {
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toContain('resize=remote')
})
it('URL includes quality=6 query param', () => {
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toContain('quality=6')
})
it('URL includes compression=2 query param', () => {
const { container } = render(<BrowserPanel sessionId="session-1" />)
const iframe = container.querySelector('iframe') as HTMLIFrameElement
expect(iframe.src).toContain('compression=2')
})
})
})
+163 -2
View File
@@ -15,9 +15,55 @@ vi.mock('react-markdown', () => ({
})) }))
// Mock MCPAppRenderer to avoid fetch/iframe in test environment // Mock MCPAppRenderer to avoid fetch/iframe in test environment
// Also captures data-runtime for visual-block assertions
vi.mock('../components/MCPAppRenderer', () => ({ vi.mock('../components/MCPAppRenderer', () => ({
MCPAppRenderer: ({ resourceUri }: { resourceUri: string }) => ( MCPAppRenderer: ({
<div data-testid='mcp-app-renderer' data-resource-uri={resourceUri} /> resourceUri,
data,
}: {
resourceUri: string
data: Record<string, unknown>
}) => (
<div
data-testid='mcp-app-renderer'
data-resource-uri={resourceUri}
data-runtime={(data?.runtime as string) ?? ''}
/>
),
}))
// Mock lib/a2ui parseA2UIBlocks parser
vi.mock('../lib/a2ui', () => ({
parseA2UIBlocks: vi.fn((content: string) => {
const blocks: Array<Record<string, unknown>> = []
const regex = /```a2ui\s*\n([\s\S]*?)```/g
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1]) as Record<string, unknown>
if (parsed && parsed.type) blocks.push(parsed)
} catch {
// ignore bad JSON
}
}
return blocks
}),
}))
// Mock A2UIRenderer component
vi.mock('../components/A2UIRenderer', () => ({
A2UIRenderer: ({
block,
onAction,
}: {
block: Record<string, unknown>
onAction?: (action: string, data: Record<string, unknown>) => void
}) => (
<div
data-testid='a2ui-renderer'
data-block-type={block.type as string}
onClick={() => onAction?.('test_action', { foo: 'bar' })}
/>
), ),
})) }))
@@ -281,4 +327,119 @@ describe('ChatPanel', () => {
expect(onSend).not.toHaveBeenCalled() expect(onSend).not.toHaveBeenCalled()
}) })
}) })
// — A2UI rendering —
describe('A2UI block rendering', () => {
const sessionId = 'session-a2ui'
it('renders A2UIRenderer for assistant message containing an a2ui block', () => {
const content = '```a2ui\n{"type":"table","headers":["Name"],"rows":[["Alice"]]}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.getByTestId('a2ui-renderer')).toBeTruthy()
})
it('does not render A2UIRenderer when assistant message has no a2ui block', () => {
const messages: ChatMessage[] = [
{ id: '1', role: 'assistant', content: 'Plain text response' },
]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.queryByTestId('a2ui-renderer')).toBeNull()
})
it('passes block type from parsed a2ui content to A2UIRenderer', () => {
const content = '```a2ui\n{"type":"filters","filters":[]}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
const renderer = screen.getByTestId('a2ui-renderer')
expect(renderer.getAttribute('data-block-type')).toBe('filters')
})
it('calls onSend with [action] {json} format when A2UI onAction fires', async () => {
const onSend = vi.fn()
const content = '```a2ui\n{"type":"table","headers":["Name"],"rows":[["Alice"]]}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={onSend} sessionId={sessionId} />
)
await userEvent.click(screen.getByTestId('a2ui-renderer'))
expect(onSend).toHaveBeenCalledWith('[test_action] {"foo":"bar"}')
})
})
// — Visual block rendering —
describe('visual block rendering', () => {
const sessionId = 'session-visual'
it('renders MCPAppRenderer with runtime=visual-artifact for a visual block', () => {
const content =
'```visual\n{"title":"Test Chart","code":"console.log(1)","data":{}}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
const renderers = screen.getAllByTestId('mcp-app-renderer')
const visualRenderer = renderers.find(
r =>
r.getAttribute('data-resource-uri') === 'app://runtime' &&
r.getAttribute('data-runtime') === 'visual-artifact'
)
expect(visualRenderer).toBeTruthy()
})
it('does not render visual MCPAppRenderer when assistant message has no visual block', () => {
const messages: ChatMessage[] = [
{ id: '1', role: 'assistant', content: 'No visual here' },
]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
const renderers = screen.queryAllByTestId('mcp-app-renderer')
const visualRenderer = renderers.find(
r => r.getAttribute('data-runtime') === 'visual-artifact'
)
expect(visualRenderer).toBeUndefined()
})
it('renders view-source and pop-out buttons in visual block header', () => {
const content =
'```visual\n{"title":"My Visual","code":"<div>hi</div>","data":{}}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.getByRole('button', { name: /view source/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /pop out/i })).toBeTruthy()
})
it('shows the visual block title', () => {
const content =
'```visual\n{"title":"Price Chart","code":"console.log(1)","data":{}}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.getByText('Price Chart')).toBeTruthy()
})
it('toggles source display when View Source button clicked', async () => {
const content =
'```visual\n{"title":"Test","code":"console.log(42)","data":{}}\n```'
const messages: ChatMessage[] = [{ id: '1', role: 'assistant', content }]
render(
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
)
expect(screen.queryByText('console.log(42)')).toBeNull()
await userEvent.click(screen.getByRole('button', { name: /view source/i }))
expect(screen.getByText('console.log(42)')).toBeTruthy()
})
})
}) })
+59 -27
View File
@@ -1,6 +1,20 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
// Hoisted mocks — must appear before component imports
vi.mock('../components/BrowserPanel', () => ({
BrowserPanel: ({ sessionId }: { sessionId: string | null }) => (
<div data-testid='browser-panel' data-session-id={sessionId ?? 'null'} />
),
}))
vi.mock('../components/ArtifactsPanel', () => ({
ArtifactsPanel: ({ sessionId }: { sessionId: string | null }) => (
<div data-testid='artifacts-panel' data-session-id={sessionId ?? 'null'} />
),
}))
import { RightPanel } from '../components/RightPanel' import { RightPanel } from '../components/RightPanel'
describe('RightPanel', () => { describe('RightPanel', () => {
@@ -33,28 +47,6 @@ describe('RightPanel', () => {
expect(artifactsBtn.classList.contains('active')).toBe(false) expect(artifactsBtn.classList.contains('active')).toBe(false)
}) })
it('shows "Select a session to view the browser/artifacts" when sessionId is null', () => {
render(<RightPanel sessionId={null} />)
expect(
screen.getByText('Select a session to view the browser/artifacts')
).toBeTruthy()
})
it('shows browser placeholder when sessionId is set and browser tab is active', () => {
render(<RightPanel sessionId='session-123' />)
expect(
screen.getByText('Browser panel (noVNC) — implemented in Phase 3')
).toBeTruthy()
})
it('shows artifacts placeholder when sessionId is set and artifacts tab is active', async () => {
render(<RightPanel sessionId='session-123' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect(
screen.getByText('Artifacts panel — implemented in Phase 3')
).toBeTruthy()
})
it('switches active class to Artifacts tab when clicked', async () => { it('switches active class to Artifacts tab when clicked', async () => {
render(<RightPanel sessionId='session-123' />) render(<RightPanel sessionId='session-123' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' })) await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
@@ -72,11 +64,51 @@ describe('RightPanel', () => {
expect(browserBtn.classList.contains('active')).toBe(true) expect(browserBtn.classList.contains('active')).toBe(true)
}) })
it('still shows no-session placeholder on artifacts tab when sessionId is null', async () => { // — BrowserPanel integration —
it('renders BrowserPanel when browser tab is active', () => {
render(<RightPanel sessionId='session-123' />)
expect(screen.getByTestId('browser-panel')).toBeTruthy()
})
it('passes sessionId to BrowserPanel', () => {
render(<RightPanel sessionId='session-xyz' />)
expect(screen.getByTestId('browser-panel').getAttribute('data-session-id')).toBe('session-xyz')
})
it('passes null sessionId to BrowserPanel when sessionId is null', () => {
render(<RightPanel sessionId={null} />)
expect(screen.getByTestId('browser-panel').getAttribute('data-session-id')).toBe('null')
})
it('does not render ArtifactsPanel when browser tab is active', () => {
render(<RightPanel sessionId='session-123' />)
expect(screen.queryByTestId('artifacts-panel')).toBeNull()
})
// — ArtifactsPanel integration —
it('renders ArtifactsPanel when artifacts tab is active', async () => {
render(<RightPanel sessionId='session-123' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect(screen.getByTestId('artifacts-panel')).toBeTruthy()
})
it('passes sessionId to ArtifactsPanel', async () => {
render(<RightPanel sessionId='session-xyz' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect(screen.getByTestId('artifacts-panel').getAttribute('data-session-id')).toBe('session-xyz')
})
it('passes null sessionId to ArtifactsPanel when sessionId is null', async () => {
render(<RightPanel sessionId={null} />) render(<RightPanel sessionId={null} />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' })) await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect( expect(screen.getByTestId('artifacts-panel').getAttribute('data-session-id')).toBe('null')
screen.getByText('Select a session to view the browser/artifacts') })
).toBeTruthy()
it('does not render BrowserPanel when artifacts tab is active', async () => {
render(<RightPanel sessionId='session-123' />)
await userEvent.click(screen.getByRole('button', { name: 'Artifacts' }))
expect(screen.queryByTestId('browser-panel')).toBeNull()
}) })
}) })
+125 -17
View File
@@ -18,21 +18,17 @@ class TestTextBlocks:
) )
def test_text_block_start(self): def test_text_block_start(self):
"""text_block_start → TEXT_MESSAGE_START with role='assistant' and messageId.""" """text_block_start → no events emitted (lazy: start is deferred to first delta)."""
event = { event = {
"event_type": "content_block:start", "event_type": "content_block:start",
"data": {"index": 0, "type": "text"}, "data": {"index": 0, "type": "text"},
} }
result = self.translator.translate(event) result = self.translator.translate(event)
assert len(result) == 1 assert result == []
ev = result[0]
assert ev.type.value == "TEXT_MESSAGE_START"
assert ev.role == "assistant"
assert ev.message_id # must have a non-empty message_id
def test_text_delta(self): def test_text_delta(self):
"""text_delta → TEXT_MESSAGE_CONTENT with delta='Hello '.""" """First text_delta → TEXT_MESSAGE_START then TEXT_MESSAGE_CONTENT (lazy start)."""
# Start the text block first # Start the text block — no event emitted yet
start_event = { start_event = {
"event_type": "content_block:start", "event_type": "content_block:start",
"data": {"index": 0, "type": "text"}, "data": {"index": 0, "type": "text"},
@@ -47,19 +43,27 @@ class TestTextBlocks:
}, },
} }
result = self.translator.translate(delta_event) result = self.translator.translate(delta_event)
assert len(result) == 1 # Lazy start: first delta emits [TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT]
ev = result[0] assert len(result) == 2
assert ev.type.value == "TEXT_MESSAGE_CONTENT" start_ev, content_ev = result
assert ev.delta == "Hello " assert start_ev.type.value == "TEXT_MESSAGE_START"
assert start_ev.role == "assistant"
assert start_ev.message_id
assert content_ev.type.value == "TEXT_MESSAGE_CONTENT"
assert content_ev.delta == "Hello "
assert content_ev.message_id == start_ev.message_id
def test_text_block_end(self): def test_text_block_end(self):
"""text_block_end → TEXT_MESSAGE_END.""" """text_block_end after content → TEXT_MESSAGE_END with matching messageId."""
# Start the block first # Start block, send a delta (which lazily opens it), then close it
start_event = { self.translator.translate({
"event_type": "content_block:start", "event_type": "content_block:start",
"data": {"index": 0, "type": "text"}, "data": {"index": 0, "type": "text"},
} })
self.translator.translate(start_event) self.translator.translate({
"event_type": "content_block:delta",
"data": {"index": 0, "delta": {"type": "text_delta", "text": "Hi"}},
})
end_event = { end_event = {
"event_type": "content_block:end", "event_type": "content_block:end",
@@ -195,6 +199,110 @@ class TestToolBlocks:
assert "structuredContent" not in d assert "structuredContent" not in d
class TestExtendedThinking:
"""Tests for extended thinking blocks — must produce zero AG-UI events."""
def setup_method(self):
self.translator = AmplifierdEventTranslator(
run_id="run_1", thread_id="thread_1"
)
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _thinking_start(self, index: int = 0) -> dict:
return {
"event_type": "content_block:start",
"data": {"index": index, "type": "thinking"},
}
def _thinking_delta(self, text: str, index: int = 0) -> dict:
return {
"event_type": "content_block:delta",
"data": {"index": index, "delta": {"type": "thinking_delta", "thinking": text}},
}
def _thinking_end(self, index: int = 0) -> dict:
return {"event_type": "content_block:end", "data": {"index": index}}
def _text_start(self, index: int = 1) -> dict:
return {
"event_type": "content_block:start",
"data": {"index": index, "type": "text"},
}
def _text_delta(self, text: str, index: int = 1) -> dict:
return {
"event_type": "content_block:delta",
"data": {"index": index, "delta": {"type": "text_delta", "text": text}},
}
def _text_end(self, index: int = 1) -> dict:
return {"event_type": "content_block:end", "data": {"index": index}}
# ------------------------------------------------------------------
# tests
# ------------------------------------------------------------------
def test_thinking_block_produces_no_events(self):
"""thinking start/delta/end → all return [] (thinking blocks are skipped)."""
assert self.translator.translate(self._thinking_start()) == []
assert self.translator.translate(self._thinking_delta("Let me reason…")) == []
assert self.translator.translate(self._thinking_end()) == []
def test_empty_text_block_produces_no_events(self):
"""text start then immediate end with no deltas → no AG-UI events emitted."""
assert self.translator.translate(self._text_start()) == []
assert self.translator.translate(self._text_end()) == []
def test_thinking_then_empty_text_produces_no_events(self):
"""Full sequence: thinking block + empty text block → zero AG-UI events total."""
events = [
self._thinking_start(index=0),
self._thinking_delta("I need to think…", index=0),
self._thinking_end(index=0),
self._text_start(index=1),
self._text_end(index=1),
]
all_results = []
for ev in events:
all_results.extend(self.translator.translate(ev))
assert all_results == []
def test_thinking_then_nonempty_text_works(self):
"""Thinking block (skip) then text block with actual content → START + CONTENT + END."""
# Thinking block — all silent
self.translator.translate(self._thinking_start(index=0))
self.translator.translate(self._thinking_delta("Reasoning…", index=0))
self.translator.translate(self._thinking_end(index=0))
# Text block — lazy open on first delta
self.translator.translate(self._text_start(index=1))
first_delta_result = self.translator.translate(self._text_delta("Hello", index=1))
assert len(first_delta_result) == 2
start_ev, content_ev = first_delta_result
assert start_ev.type.value == "TEXT_MESSAGE_START"
assert start_ev.role == "assistant"
message_id = start_ev.message_id
assert content_ev.type.value == "TEXT_MESSAGE_CONTENT"
assert content_ev.delta == "Hello"
assert content_ev.message_id == message_id
# Subsequent delta — only CONTENT (no second START)
second_delta_result = self.translator.translate(self._text_delta(" world", index=1))
assert len(second_delta_result) == 1
assert second_delta_result[0].type.value == "TEXT_MESSAGE_CONTENT"
assert second_delta_result[0].delta == " world"
# End — TEXT_MESSAGE_END because content was started
end_result = self.translator.translate(self._text_end(index=1))
assert len(end_result) == 1
assert end_result[0].type.value == "TEXT_MESSAGE_END"
assert end_result[0].message_id == message_id
class TestLifecycle: class TestLifecycle:
"""Tests for lifecycle events → AG-UI run lifecycle events.""" """Tests for lifecycle events → AG-UI run lifecycle events."""
+2 -2
View File
@@ -60,12 +60,12 @@ check_contains "$A2UI" "a2ui fenced code block" '```a2ui'
echo "" echo ""
echo "=== Checking a2ui-format.md: block type 'filters' ===" echo "=== Checking a2ui-format.md: block type 'filters' ==="
check_contains "$A2UI" "filters type" '"type": "filters"' check_contains "$A2UI" "filters type" '"type": "filters"'
check_contains "$A2UI" "filters options array" '"options"' check_contains "$A2UI" "filters array key" '"filters"'
check_contains "$A2UI" "Toyota option" '"Toyota"' check_contains "$A2UI" "Toyota option" '"Toyota"'
check_contains "$A2UI" "Honda option" '"Honda"' check_contains "$A2UI" "Honda option" '"Honda"'
check_contains "$A2UI" "Hyundai option" '"Hyundai"' check_contains "$A2UI" "Hyundai option" '"Hyundai"'
check_contains "$A2UI" "Under \$5K option" '"Under $5K"' check_contains "$A2UI" "Under \$5K option" '"Under $5K"'
check_contains "$A2UI" "active field" '"active"' check_contains "$A2UI" "value field" '"value"'
echo "" echo ""
echo "=== Checking a2ui-format.md: block type 'table' ===" echo "=== Checking a2ui-format.md: block type 'table' ==="
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# test_docker_compose.sh - Verify docker-compose.yml matches spec for task-12
# RED: Run before docker-compose.yml exists (should fail)
# GREEN: Run after docker-compose.yml is created (should pass)
REPO=/home/ken/workspace/research-workbench
COMPOSE="$REPO/docker-compose.yml"
PASS=0
FAIL=0
check_file() {
if [ -f "$1" ]; then
echo "PASS: file '$1' exists"
((PASS++))
else
echo "FAIL: file '$1' should exist but is missing"
((FAIL++))
fi
}
check_contains() {
local pattern="$1"
local label="$2"
if grep -qF -- "$pattern" "$COMPOSE" 2>/dev/null; then
echo "PASS: docker-compose.yml contains $label"
((PASS++))
else
echo "FAIL: docker-compose.yml missing $label"
((FAIL++))
fi
}
check_regex() {
local pattern="$1"
local label="$2"
if grep -qE "$pattern" "$COMPOSE" 2>/dev/null; then
echo "PASS: docker-compose.yml contains $label"
((PASS++))
else
echo "FAIL: docker-compose.yml missing $label"
((FAIL++))
fi
}
echo "=== Checking docker-compose.yml exists ==="
check_file "$COMPOSE"
echo ""
echo "=== Checking prerequisite comments ==="
check_contains "npm run build" "frontend build prerequisite comment"
check_contains "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEY prerequisite comment"
check_contains "bcrypt" "bcrypt password hash prerequisite comment"
echo ""
echo "=== Checking services ==="
check_contains "services:" "services section"
check_contains "workbench:" "workbench service"
echo ""
echo "=== Checking build ==="
check_regex "build: \." "build: ."
echo ""
echo "=== Checking ports ==="
check_contains "8080:8080" "port 8080:8080 (Web UI)"
check_contains "6080:6080" "port 6080:6080 (noVNC browser view)"
echo ""
echo "=== Checking environment variables ==="
check_regex "ANTHROPIC_API_KEY=\\\$\{ANTHROPIC_API_KEY\}" "ANTHROPIC_API_KEY env var"
check_regex "AUTH_USER=\\\$\{AUTH_USER:-admin@localhost\}" "AUTH_USER env var with default"
check_regex "AUTH_PASS_HASH=\\\$\{AUTH_PASS_HASH\}" "AUTH_PASS_HASH env var"
check_contains "DISPLAY=:99" "DISPLAY=:99 env var"
echo ""
echo "=== Checking volumes ==="
check_contains "./artifacts:/app/artifacts" "artifacts volume"
echo ""
echo "=== Checking restart policy ==="
check_contains "restart: unless-stopped" "restart unless-stopped"
echo ""
echo "=============================="
echo "Results: $PASS passed, $FAIL failed"
echo "=============================="
[ $FAIL -eq 0 ] && exit 0 || exit 1
+3 -3
View File
@@ -128,17 +128,17 @@ check_regex "user can take over via VNC" "VNC|vnc"
echo "" echo ""
echo "=== Checking body: A2UI section ===" echo "=== Checking body: A2UI section ==="
check_contains "A2UI section header" "A2UI" check_contains "A2UI section header" "A2UI"
check_contains "a2ui-format.md @mention" "bundle/context/a2ui-format.md" check_contains "a2ui-format @mention" "bundle/context/a2ui-format"
echo "" echo ""
echo "=== Checking body: Artifacts section ===" echo "=== Checking body: Artifacts section ==="
check_contains "Artifacts section header" "Artifacts" check_contains "Artifacts section header" "Artifacts"
check_contains "artifact-conventions.md @mention" "bundle/context/artifact-conventions.md" check_contains "artifact-conventions @mention" "bundle/context/artifact-conventions"
echo "" echo ""
echo "=== Checking body: Inline Visuals section ===" echo "=== Checking body: Inline Visuals section ==="
check_contains "Inline Visuals section header" "Inline Visuals" check_contains "Inline Visuals section header" "Inline Visuals"
check_contains "visual-artifacts.md @mention" "bundle/context/visual-artifacts.md" check_contains "visual-artifacts @mention" "bundle/context/visual-artifacts"
check_regex "proactive visual generation mention" "proactive|visual.*generation|comparison.*table|chart" check_regex "proactive visual generation mention" "proactive|visual.*generation|comparison.*table|chart"
echo "" echo ""
+307
View File
@@ -0,0 +1,307 @@
"""
Tests for bundle/apps/runtime.html
Verifies the visual artifact runtime HTML file exists with all required
CDN references, script structures, and message protocol implementations
as specified in task-5.
"""
import os
import pytest
HTML_FILE = os.path.join(
os.path.dirname(__file__),
"..",
"bundle",
"apps",
"runtime.html",
)
@pytest.fixture(scope="module")
def html_content():
assert os.path.exists(HTML_FILE), f"runtime.html not found at {HTML_FILE}"
with open(HTML_FILE) as f:
return f.read()
class TestFileExists:
def test_runtime_html_exists(self):
assert os.path.exists(HTML_FILE), f"runtime.html not found at {HTML_FILE}"
def test_is_complete_html_document(self, html_content):
assert (
"<!DOCTYPE html>" in html_content
or "<!doctype html>" in html_content.lower()
)
assert "<html" in html_content
assert "</html>" in html_content
class TestCDNLibraries:
"""Verify all required CDN libraries are present in the head section."""
def test_tabler_css_cdn(self, html_content):
assert (
"https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css"
in html_content
)
def test_leaflet_css_cdn(self, html_content):
assert (
"https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.css" in html_content
)
def test_chartjs_cdn(self, html_content):
assert "https://cdn.jsdelivr.net/npm/chart.js@4" in html_content
def test_leaflet_js_cdn(self, html_content):
assert (
"https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.js" in html_content
)
def test_react_19_umd(self, html_content):
# React 19 production UMD
assert "react@19" in html_content
assert (
"umd" in html_content.lower()
or "unpkg.com" in html_content
or "cdn.jsdelivr.net" in html_content
)
def test_react_dom_19_umd(self, html_content):
# ReactDOM 19 production UMD
assert "react-dom@19" in html_content
def test_babel_standalone_7(self, html_content):
assert "@babel/standalone@7" in html_content
assert "babel.min.js" in html_content
class TestStyles:
"""Verify required CSS styles."""
def test_transparent_body_background(self, html_content):
# transparent bg
assert "transparent" in html_content
def test_dark_color_scheme(self, html_content):
assert (
"color-scheme: dark" in html_content or "color-scheme:dark" in html_content
)
def test_body_padding_16px(self, html_content):
assert "16px" in html_content
def test_visual_error_class(self, html_content):
assert ".visual-error" in html_content
def test_visual_error_red_color(self, html_content):
# red text color for error class
assert (
"#ff" in html_content.lower()
or "red" in html_content
or "color: #" in html_content
)
def test_visual_error_monospace(self, html_content):
assert "monospace" in html_content
def test_visual_error_pre_wrap(self, html_content):
assert "pre-wrap" in html_content
class TestBodyStructure:
"""Verify body has root div."""
def test_root_div_exists(self, html_content):
assert '<div id="root">' in html_content or "<div id='root'>" in html_content
class TestMcpBridge:
"""Verify mcpBridge object implementation."""
def test_mcp_bridge_declared(self, html_content):
assert "mcpBridge" in html_content
def test_pending_map(self, html_content):
assert "_pending" in html_content
assert "Map" in html_content
def test_send_to_host_method(self, html_content):
assert "sendToHost" in html_content
assert "window.parent.postMessage" in html_content
def test_call_tool_method(self, html_content):
assert "callTool" in html_content
def test_call_tool_jsonrpc_20(self, html_content):
# jsonrpc key may appear as unquoted JS property (jsonrpc:) or quoted ("jsonrpc" / 'jsonrpc')
assert (
'"jsonrpc"' in html_content
or "'jsonrpc'" in html_content
or "jsonrpc:" in html_content
)
assert '"2.0"' in html_content or "'2.0'" in html_content
def test_call_tool_method_name(self, html_content):
assert "tools/call" in html_content
def test_call_tool_returns_promise(self, html_content):
assert "Promise" in html_content
def test_call_tool_uses_uuid(self, html_content):
# Random UUID for call id
assert (
"crypto" in html_content
or "Math.random" in html_content
or "uuid" in html_content.lower()
)
def test_send_message_method(self, html_content):
assert "sendMessage" in html_content
assert "ui/sendMessage" in html_content
def test_send_message_role_user(self, html_content):
assert "role" in html_content
assert "user" in html_content
def test_resize_method(self, html_content):
assert "resize" in html_content
assert "ui/resize" in html_content
def test_resize_capped_at_800(self, html_content):
assert "800" in html_content
assert "Math.min" in html_content
class TestExecuteVisual:
"""Verify executeVisual function implementation."""
def test_execute_visual_function(self, html_content):
assert "executeVisual" in html_content
def test_sets_visual_data_global(self, html_content):
assert "__VISUAL_DATA__" in html_content
def test_sets_mcp_global(self, html_content):
assert "__MCP__" in html_content
def test_jsx_detection_capital_tag_regex(self, html_content):
# Regex for JSX detection: /<[A-Z]/
assert "<[A-Z]" in html_content
def test_jsx_detection_react_create_element(self, html_content):
assert "React.createElement" in html_content
def test_jsx_detection_type_text_babel(self, html_content):
# The regex pattern in JS source: /type="text\/babel"/ contains text\/babel
assert "text/babel" in html_content or r"text\/babel" in html_content
def test_jsx_path_uses_babel_transform(self, html_content):
assert "Babel.transform" in html_content
assert "react" in html_content
def test_jsx_path_auto_render_app(self, html_content):
assert "App" in html_content
assert "ReactDOM.createRoot" in html_content
def test_html_path_detection(self, html_content):
# HTML path: code contains <[a-z]
assert "<[a-z]" in html_content
def test_html_path_sets_inner_html(self, html_content):
assert "innerHTML" in html_content
def test_html_path_clones_script_tags(self, html_content):
# Clone script elements for HTML path
assert "createElement" in html_content
assert "script" in html_content.lower()
def test_html_path_inline_scripts_wrapped(self, html_content):
# Inline scripts wrapped with data and mcp params
assert "__VISUAL_DATA__" in html_content
assert "__MCP__" in html_content
def test_plain_js_uses_new_function(self, html_content):
assert "new Function" in html_content
def test_plain_js_passes_chart_arg(self, html_content):
assert (
'"Chart"' in html_content
or "'Chart'" in html_content
or "Chart" in html_content
)
def test_plain_js_passes_leaflet_arg(self, html_content):
assert '"L"' in html_content or "'L'" in html_content
def test_auto_resize_via_raf(self, html_content):
assert "requestAnimationFrame" in html_content
def test_auto_resize_scroll_height(self, html_content):
assert "scrollHeight" in html_content
def test_auto_resize_adds_32(self, html_content):
assert "32" in html_content
def test_auto_resize_min_100(self, html_content):
assert "100" in html_content
assert "Math.max" in html_content
def test_error_renders_visual_error_div(self, html_content):
assert "visual-error" in html_content
def test_error_resize_120(self, html_content):
assert "120" in html_content
class TestMessageListener:
"""Verify window message event listener."""
def test_message_event_listener(self, html_content):
assert "addEventListener" in html_content
assert '"message"' in html_content or "'message'" in html_content
def test_pending_response_handler(self, html_content):
# If msg.id in _pending, resolve and delete
assert "_pending" in html_content
assert "resolve" in html_content
assert "delete" in html_content
def test_render_method_handler(self, html_content):
assert '"render"' in html_content or "'render'" in html_content
def test_render_calls_execute_visual(self, html_content):
assert "executeVisual" in html_content
def test_render_params_code(self, html_content):
assert "params" in html_content
assert "code" in html_content
def test_legacy_tool_input_method(self, html_content):
assert "tool/input" in html_content
def test_legacy_tool_input_triggers_render(self, html_content):
# Legacy tool/input with code param should call executeVisual
assert "tool/input" in html_content
assert "executeVisual" in html_content
class TestInitialHandshake:
"""Verify initial ui/initialize handshake is sent on load."""
def test_handshake_sent_on_load(self, html_content):
assert "ui/initialize" in html_content
def test_handshake_runtime_visual_artifact(self, html_content):
assert "visual-artifact" in html_content
def test_handshake_version_1_0_0(self, html_content):
assert "1.0.0" in html_content
def test_handshake_uses_send_to_host(self, html_content):
assert (
"sendToHost" in html_content or "window.parent.postMessage" in html_content
)
+230
View File
@@ -0,0 +1,230 @@
#!/bin/bash
# test_visual_artifacts_md.sh - Verify bundle/context/visual-artifacts.md (task-6)
# RED: Run before file is created (should fail)
# GREEN: Run after file is created (should pass)
REPO=/home/ken/workspace/research-workbench
PASS=0
FAIL=0
check_file_exists() {
local path="$1"
if [ -f "$REPO/$path" ]; then
echo "PASS: '$path' exists"
((PASS++))
else
echo "FAIL: '$path' should exist but is missing"
((FAIL++))
fi
}
check_contains() {
local file="$1"
local description="$2"
local pattern="$3"
if grep -qF -- "$pattern" "$REPO/$file" 2>/dev/null; then
echo "PASS: $file contains '$description'"
((PASS++))
else
echo "FAIL: $file is missing '$description'"
((FAIL++))
fi
}
check_regex() {
local file="$1"
local description="$2"
local pattern="$3"
if grep -qE "$pattern" "$REPO/$file" 2>/dev/null; then
echo "PASS: $file matches '$description'"
((PASS++))
else
echo "FAIL: $file is missing '$description' (pattern: $pattern)"
((FAIL++))
fi
}
TARGET="bundle/context/visual-artifacts.md"
# ─────────────────────────────────────────────────────────────────────────────
# File existence
# ─────────────────────────────────────────────────────────────────────────────
echo "=== Checking $TARGET exists ==="
check_file_exists "$TARGET"
# ─────────────────────────────────────────────────────────────────────────────
# Intro section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Intro section ==="
check_regex "$TARGET" "intro / overview heading" "^# |^## "
check_contains "$TARGET" "visual fenced code block tag" '```visual'
check_regex "$TARGET" "sandboxed iframe mention" 'sandbox|iframe|sandboxed'
check_regex "$TARGET" "pre-loaded libraries mention" 'pre.loaded|preloaded|pre-loaded'
# ─────────────────────────────────────────────────────────────────────────────
# When to Generate section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking When to Generate section ==="
check_regex "$TARGET" "When to Generate heading" 'When to Generate|When to Use'
check_regex "$TARGET" "comparing options trigger" 'compar'
check_regex "$TARGET" "trends trigger" 'trend'
check_regex "$TARGET" "locations trigger" 'locat'
check_regex "$TARGET" "scoring/ranking trigger" 'scor|rank'
check_regex "$TARGET" "budget/breakdown trigger" 'budget|breakdown'
check_regex "$TARGET" "dashboard trigger" 'dashboard'
check_regex "$TARGET" "proactive / don't wait to be asked" "proactive|don.t wait|without being asked"
# ─────────────────────────────────────────────────────────────────────────────
# Available Libraries section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Available Libraries section ==="
check_regex "$TARGET" "Available Libraries heading" 'Available Lib|Libraries.*pre'
check_regex "$TARGET" "Tabler CSS entry" 'Tabler'
check_regex "$TARGET" "Chart.js entry" 'Chart\.js|Chart'
check_contains "$TARGET" "Chart global variable" '`Chart`'
check_regex "$TARGET" "Leaflet.js entry" 'Leaflet'
check_contains "$TARGET" "L global variable" '`L`'
check_regex "$TARGET" "React v19 entry" 'React.*19|React v19'
check_contains "$TARGET" "React global" '`React`'
check_contains "$TARGET" "ReactDOM global" '`ReactDOM`'
check_regex "$TARGET" "Babel entry" 'Babel'
check_regex "$TARGET" "JSX compilation" 'JSX|jsx'
# ─────────────────────────────────────────────────────────────────────────────
# Output Format section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Output Format section ==="
check_regex "$TARGET" "Output Format heading" 'Output Format'
check_contains "$TARGET" "visual tag in output example" '```visual'
check_regex "$TARGET" "title field" '"title"'
check_regex "$TARGET" "code field" '"code"'
check_regex "$TARGET" "data field" '"data"'
check_regex "$TARGET" "height field" '"height"'
check_regex "$TARGET" "height default 300" '300'
check_regex "$TARGET" "height max 800" '800'
# ─────────────────────────────────────────────────────────────────────────────
# Code Formats section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Code Formats section ==="
check_regex "$TARGET" "Code Formats heading" 'Code Format'
check_regex "$TARGET" "JSX auto-detection" 'JSX|React.*detect|detect.*React'
check_regex "$TARGET" "HTML detection" 'HTML.*detect|detect.*HTML|<div|<table'
check_regex "$TARGET" "Plain JS format" 'Plain JS|plain.*js|JS.*function'
check_regex "$TARGET" "Babel compilation" 'Babel.*compil|compil.*Babel'
check_regex "$TARGET" "App component auto-render" 'App.*component|auto.render|auto-render'
check_contains "$TARGET" "#root element" '#root'
# ─────────────────────────────────────────────────────────────────────────────
# Code Scope section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Code Scope section ==="
check_regex "$TARGET" "Code Scope heading" 'Code Scope|Available.*Scope|Scope.*Variables'
check_contains "$TARGET" "data variable in scope" '`data`'
check_contains "$TARGET" "mcp variable in scope" '`mcp`'
check_contains "$TARGET" "root variable in scope" '`root`'
check_contains "$TARGET" "Chart variable in scope" '`Chart`'
check_contains "$TARGET" "L variable in scope" '`L`'
check_regex "$TARGET" "mcp.sendMessage" 'sendMessage|mcp\.sendMessage'
check_regex "$TARGET" "mcp.callTool" 'callTool|mcp\.callTool'
check_regex "$TARGET" "mcp.resize" 'resize|mcp\.resize'
# ─────────────────────────────────────────────────────────────────────────────
# Example Patterns section - all 5
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Example 1: Comparison Table ==="
check_regex "$TARGET" "Example 1 heading" 'Comparison Table|Example.*1'
check_regex "$TARGET" "Hybrid Cars title" 'Hybrid Cars Under|Hybrid Cars.*10K'
check_regex "$TARGET" "table headers (Model/Year/Price/Miles/MPG)" 'Model|Year|Price|Miles|MPG'
check_regex "$TARGET" "Prius sample data" 'Prius'
check_regex "$TARGET" "data.items reference" 'data\.items|data\[.items'
check_regex "$TARGET" "green price color" '#7af7a2|7af7a2'
check_regex "$TARGET" "blue link color" '#7aa2f7|7aa2f7'
check_regex "$TARGET" "height 280" '"height".*280|280.*height|height.*280'
echo ""
echo "=== Checking Example 2: Bar Chart ==="
check_regex "$TARGET" "Example 2 heading" 'Bar Chart|Example.*2'
check_regex "$TARGET" "Price Comparison title" 'Price Comparison by Model|Price Comparison'
check_regex "$TARGET" "Chart.js new Chart" 'new Chart'
check_regex "$TARGET" "type bar" "type.*bar|bar.*type|type.*'bar'"
check_regex "$TARGET" "data.labels reference" 'data\.labels'
check_regex "$TARGET" "data.values reference" 'data\.values'
check_regex "$TARGET" "blue rgba color" 'rgba.*122.*162.*247|rgba\(122'
check_regex "$TARGET" "onClick with mcp.sendMessage" 'onClick|mcp\.sendMessage.*label|mcp\.sendMessage.*more about'
check_regex "$TARGET" "height 380 for chart" '"height".*380|380.*height|height.*380'
echo ""
echo "=== Checking Example 3: Location Map ==="
check_regex "$TARGET" "Example 3 heading" 'Location Map|Map|Example.*3'
check_regex "$TARGET" "Dealers Near Woodinville title" 'Dealers Near Woodinville|Woodinville'
check_regex "$TARGET" "L.map usage" 'L\.map'
check_regex "$TARGET" "CARTO dark tile layer" 'CARTO|cartocdn|carto'
check_regex "$TARGET" "markers from data.markers" 'data\.markers'
check_regex "$TARGET" "bindPopup" 'bindPopup'
check_regex "$TARGET" "marker click mcp.sendMessage" 'mcp\.sendMessage.*title|click.*mcp|marker.*mcp'
check_regex "$TARGET" "fitBounds for multiple markers" 'fitBounds'
echo ""
echo "=== Checking Example 4: React Dashboard ==="
check_regex "$TARGET" "Example 4 heading" 'React Dashboard|Dashboard|Example.*4'
check_regex "$TARGET" "Research Summary Dashboard title" 'Research Summary Dashboard|Research Summary'
check_regex "$TARGET" "StatCard component" 'StatCard'
check_regex "$TARGET" "App component" 'function App|const App|App ='
check_regex "$TARGET" "useState for sort" 'useState'
check_regex "$TARGET" "sort by price or miles" "sort|price.*miles|miles.*price"
check_regex "$TARGET" "Tabler btn classes" 'btn-primary|btn-secondary|btn tabler|tabler.*btn'
check_regex "$TARGET" "height 450" '"height".*450|450.*height|height.*450'
echo ""
echo "=== Checking Example 5: Decision Matrix ==="
check_regex "$TARGET" "Example 5 heading" 'Decision Matrix|Example.*5'
check_regex "$TARGET" "Car Decision Matrix title" 'Car Decision Matrix|Decision Matrix'
check_regex "$TARGET" "weight sliders (range input)" 'range.*slider|slider.*range|type.*range|range.*0.*10'
check_regex "$TARGET" "criteria (Price, MPG, Reliability)" 'Price|MPG|Reliab'
check_regex "$TARGET" "weighted scores calculation" 'weight.*score|score.*weight|weighted'
check_regex "$TARGET" "best highlighted green" 'best.*green|green.*best|highlight.*green|green.*highlight'
check_regex "$TARGET" "height 400" '"height".*400|400.*height|height.*400'
# ─────────────────────────────────────────────────────────────────────────────
# Constraints section
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Constraints section ==="
check_regex "$TARGET" "Constraints heading" 'Constraint'
check_regex "$TARGET" "self-contained / no API fetches" 'self.contained|no.*fetch|no.*API fetch|no.*external fetch'
check_regex "$TARGET" "dark theme constraint" 'dark.*theme|theme.*dark'
check_regex "$TARGET" "responsive width constraint" 'responsive|percentage|max.width'
check_regex "$TARGET" "no external fetches" 'no.*external|external.*fetch'
check_regex "$TARGET" "click-to-explore" 'click.to.explore|click.*explore|explore.*click'
# ─────────────────────────────────────────────────────────────────────────────
# Visuals vs A2UI decision table
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Checking Visuals vs A2UI section ==="
check_regex "$TARGET" "Visuals vs A2UI heading" 'Visuals.*A2UI|A2UI.*Visuals|visual.*vs.*a2ui|vs.*A2UI'
check_regex "$TARGET" "toggle filters -> A2UI" 'filter|toggle'
check_regex "$TARGET" "simple table -> A2UI" 'simple.*table|table.*simple|<5.*row|5.*row'
check_regex "$TARGET" "interactive charts -> visual" 'interactive.*chart|chart.*interactive'
check_regex "$TARGET" "sortable tables -> visual" 'sortable'
check_regex "$TARGET" "maps -> visual" 'map'
check_regex "$TARGET" "dashboards -> visual" 'dashboard'
check_regex "$TARGET" "decision matrices -> visual" 'decision.*matrix|matrix'
# ─────────────────────────────────────────────────────────────────────────────
# Summary
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=============================="
echo "Results: $PASS passed, $FAIL failed"
echo "=============================="
[ $FAIL -eq 0 ] && exit 0 || exit 1