From 11b3bc2bbf00889a2cd8524438afcaea0fa5b285 Mon Sep 17 00:00:00 2001 From: Ken Date: Wed, 27 May 2026 01:42:52 +0000 Subject: [PATCH] fix: chat functionality and extended thinking handling - 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 --- Dockerfile | 3 +- backend/agui_adapter.py | 100 +++++++++++++++++++----- bundle/bundle.md | 11 +++ frontend/src/hooks/useChat.ts | 2 +- tests/test_agui_adapter.py | 142 ++++++++++++++++++++++++++++++---- 5 files changed, 219 insertions(+), 39 deletions(-) diff --git a/Dockerfile b/Dockerfile index 16b9832..e18829a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,8 @@ ENV UV_LINK_MODE=copy # 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" + "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. diff --git a/backend/agui_adapter.py b/backend/agui_adapter.py index dfb0471..9b70adc 100644 --- a/backend/agui_adapter.py +++ b/backend/agui_adapter.py @@ -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.""" diff --git a/bundle/bundle.md b/bundle/bundle.md index 8b497d0..57a21ea 100644 --- a/bundle/bundle.md +++ b/bundle/bundle.md @@ -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 + +orchestrator: + module: loop-streaming + config: + extended_thinking: false + includes: - bundle: git+https://github.com/microsoft/amplifier-foundation@main - bundle: research-workbench:behaviors/research-workbench diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts index 2ac2d5a..3734f43 100644 --- a/frontend/src/hooks/useChat.ts +++ b/frontend/src/hooks/useChat.ts @@ -95,7 +95,7 @@ export function useChat(sessionId: string | null) { break } case 'TEXT_MESSAGE_END': { - // no-op + assistantId = null break } case 'TOOL_CALL_START': { diff --git a/tests/test_agui_adapter.py b/tests/test_agui_adapter.py index 4614685..89cdd55 100644 --- a/tests/test_agui_adapter.py +++ b/tests/test_agui_adapter.py @@ -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."""