Compare commits

...

9 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
10 changed files with 386 additions and 70 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
# 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 \
xvfb \
x11vnc \
websockify \
novnc \
curl \
git \
libnss3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
@@ -25,40 +28,58 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-liberation \
&& 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 - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install @anthropic-ai/playwright-cli globally via npm
RUN npm install -g @anthropic-ai/playwright-cli
# ── npm global tools: @playwright/cli + Chrome ────────────────────────────────
# @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
# 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/
WORKDIR /app/backend
RUN uv sync --frozen
# Install Playwright chromium
RUN uv run playwright install chromium
# Install amplifierd
RUN uv pip install amplifierd
# Copy application files
# ── Application files ─────────────────────────────────────────────────────────
COPY backend/*.py /app/backend/
COPY bundle/ /app/bundle/
COPY frontend/dist/ /app/frontend/dist/
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create artifacts directory
RUN mkdir -p /app/artifacts
WORKDIR /app
EXPOSE 8080 6080
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.
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:
List of ag_ui.core event objects (may be empty for unknown events).
"""
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":
return self._handle_block_start(data)
@@ -62,9 +68,13 @@ class AmplifierdEventTranslator:
return []
def _handle_block_start(self, data: dict[str, Any]) -> list[Any]:
"""Handle content_block:start — emit TEXT_MESSAGE_START or TOOL_CALL_START."""
index = data.get("index", 0)
block_type = data.get("type", "text")
"""Handle content_block:start — register block; defer TEXT_MESSAGE_START to first delta.
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":
tool_id = data.get("id", "")
@@ -81,21 +91,27 @@ class AmplifierdEventTranslator:
parent_message_id=self._current_message_id,
)
]
else:
# Text block
elif block_type == "text":
# Lazy start: don't emit TEXT_MESSAGE_START until we know there's actual content.
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
return [
TextMessageStartEvent(
message_id=message_id,
role="assistant",
)
]
return []
else:
# thinking, image, or any other block type — skip entirely
self._blocks[index] = {"type": "skip"}
return []
def _handle_block_delta(self, data: dict[str, Any]) -> list[Any]:
"""Handle content_block:delta — emit TEXT_MESSAGE_CONTENT or TOOL_CALL_ARGS."""
index = data.get("index", 0)
"""Handle content_block:delta — emit TEXT_MESSAGE_CONTENT or TOOL_CALL_ARGS.
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_type = delta.get("type", "")
@@ -112,6 +128,9 @@ class AmplifierdEventTranslator:
]
return []
if block_info["type"] == "skip":
return []
if block_info["type"] == "tool_use":
return [
ToolCallArgsEvent(
@@ -120,10 +139,22 @@ class AmplifierdEventTranslator:
)
]
else:
# Text block
# Text block — lazy start: emit TEXT_MESSAGE_START on the first real token
token = delta.get("text", "")
if not token:
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 [
TextMessageContentEvent(
message_id=block_info["message_id"],
@@ -132,17 +163,46 @@ class AmplifierdEventTranslator:
]
def _handle_block_end(self, data: dict[str, Any]) -> list[Any]:
"""Handle content_block:end — emit TEXT_MESSAGE_END or TOOL_CALL_END."""
index = data.get("index", 0)
"""Handle content_block:end — emit TEXT_MESSAGE_END or TOOL_CALL_END.
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)
if block_info is None:
return []
if block_info["type"] == "skip":
return []
if block_info["type"] == "tool_use":
return [ToolCallEndEvent(tool_call_id=block_info["tool_id"])]
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]:
"""Handle tool:result — emit TOOL_CALL_RESULT with optional MCP App metadata."""
+18 -5
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
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)
You control the browser using `playwright-cli` with a persistent named session
(`-s=research`). All commands operate on the same browser session, preserving tabs
and state across the entire conversation.
You control the browser using `playwright-cli` via the bash tool. All commands use
a persistent named session (`-s=research`), preserving tabs and state across the
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
@@ -109,9 +122,9 @@ and state across the entire conversation.
|--------|---------|
| Open a URL | `playwright-cli -s=research open https://example.com` |
| 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` |
| 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` |
| Scroll down | `playwright-cli -s=research scroll down` |
| Go back | `playwright-cli -s=research back` |
+11
View File
@@ -4,6 +4,17 @@ bundle:
version: 0.1.0
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:
- bundle: git+https://github.com/microsoft/amplifier-foundation@main
- bundle: research-workbench:behaviors/research-workbench
+44 -10
View File
@@ -3,22 +3,56 @@
This session runs inside the **Research Workbench**. You have access to the
following capabilities:
## Available Capabilities
## CRITICAL: Pre-installed Tools
**Browser** — Live Chromium browser controlled via `playwright-cli -s=research`.
The browser is persistent across commands and visible to the user via VNC (noVNC
panel). The user can take over at any time.
All tools are **already installed**. Do NOT run npm install, pip install, or any
package manager. Just use the commands directly via the bash tool.
**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.
**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.
**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.
## Full Command Reference
## Research Workflow
For complete command syntax, workflow guidance, and best practices, see the
researcher agent system prompt.
1. Understand the request — clarify scope and constraints
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
+4 -1
View File
@@ -21,8 +21,11 @@ sleep 1
echo "[OK] noVNC on :6080"
# (4) Start amplifierd and wait for it to be healthy
# amplifierd uses a 'serve' subcommand; --bundle requires NAME=URI format
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=$!
echo "Waiting for amplifierd..."
+1 -1
View File
@@ -95,7 +95,7 @@ export function useChat(sessionId: string | null) {
break
}
case 'TEXT_MESSAGE_END': {
// no-op
assistantId = null
break
}
case 'TOOL_CALL_START': {
+125 -17
View File
@@ -18,21 +18,17 @@ class TestTextBlocks:
)
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_type": "content_block:start",
"data": {"index": 0, "type": "text"},
}
result = self.translator.translate(event)
assert len(result) == 1
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
assert result == []
def test_text_delta(self):
"""text_delta → TEXT_MESSAGE_CONTENT with delta='Hello '."""
# Start the text block first
"""First text_delta → TEXT_MESSAGE_START then TEXT_MESSAGE_CONTENT (lazy start)."""
# Start the text block — no event emitted yet
start_event = {
"event_type": "content_block:start",
"data": {"index": 0, "type": "text"},
@@ -47,19 +43,27 @@ class TestTextBlocks:
},
}
result = self.translator.translate(delta_event)
assert len(result) == 1
ev = result[0]
assert ev.type.value == "TEXT_MESSAGE_CONTENT"
assert ev.delta == "Hello "
# Lazy start: first delta emits [TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT]
assert len(result) == 2
start_ev, content_ev = result
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):
"""text_block_end → TEXT_MESSAGE_END."""
# Start the block first
start_event = {
"""text_block_end after content → TEXT_MESSAGE_END with matching messageId."""
# Start block, send a delta (which lazily opens it), then close it
self.translator.translate({
"event_type": "content_block:start",
"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 = {
"event_type": "content_block:end",
@@ -195,6 +199,110 @@ class TestToolBlocks:
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:
"""Tests for lifecycle events → AG-UI run lifecycle events."""