diff --git a/cockpit/chat/generative-ui/python/src/graph.py b/cockpit/chat/generative-ui/python/src/graph.py index e5e6725e3..9dbc1ea9f 100644 --- a/cockpit/chat/generative-ui/python/src/graph.py +++ b/cockpit/chat/generative-ui/python/src/graph.py @@ -19,7 +19,7 @@ _PROMPT = (Path(__file__).parent.parent / "prompts" / "dashboard.md").read_text() -_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, streaming=True) +_llm = ChatOpenAI(model="gpt-5-mini", temperature=0, streaming=True) _llm_with_tools = _llm.bind_tools(ALL_TOOLS) diff --git a/cockpit/deep-agents/filesystem/python/docs/guide.md b/cockpit/deep-agents/filesystem/python/docs/guide.md index 3118fb705..e95fc610a 100644 --- a/cockpit/deep-agents/filesystem/python/docs/guide.md +++ b/cockpit/deep-agents/filesystem/python/docs/guide.md @@ -139,7 +139,7 @@ def write_file(path: str, content: str) -> str: return f"Successfully wrote {len(content)} bytes to {path}" # Bind tools to the LLM -llm = ChatOpenAI(model="gpt-4o-mini").bind_tools([read_file, write_file]) +llm = ChatOpenAI(model="gpt-5-mini").bind_tools([read_file, write_file]) ``` The agent node invokes the LLM, which may emit tool calls. A conditional edge routes to the `ToolNode` when tool calls are present, then loops back to the agent. The frontend sees each tool call in `stream.messages()`. diff --git a/cockpit/deep-agents/filesystem/python/src/graph.py b/cockpit/deep-agents/filesystem/python/src/graph.py index 5e5ce0091..8e3083077 100644 --- a/cockpit/deep-agents/filesystem/python/src/graph.py +++ b/cockpit/deep-agents/filesystem/python/src/graph.py @@ -35,7 +35,7 @@ class FilesystemState(TypedDict): def build_filesystem_graph(): tools = [read_file, write_file] - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True).bind_tools(tools) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True).bind_tools(tools) async def agent(state: FilesystemState) -> dict: """Run the agent — may emit tool calls.""" diff --git a/cockpit/deep-agents/memory/python/src/graph.py b/cockpit/deep-agents/memory/python/src/graph.py index 1574f3e0f..5137ee2ce 100644 --- a/cockpit/deep-agents/memory/python/src/graph.py +++ b/cockpit/deep-agents/memory/python/src/graph.py @@ -24,7 +24,7 @@ class MemoryState(TypedDict): def build_memory_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: MemoryState) -> dict: """Generate a response using remembered facts in the system prompt.""" diff --git a/cockpit/deep-agents/planning/python/src/graph.py b/cockpit/deep-agents/planning/python/src/graph.py index ccb45add6..29643b9e2 100644 --- a/cockpit/deep-agents/planning/python/src/graph.py +++ b/cockpit/deep-agents/planning/python/src/graph.py @@ -27,7 +27,7 @@ class PlanningState(TypedDict): def build_planning_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def create_plan(state: PlanningState) -> dict: """Decompose the task into ordered steps.""" diff --git a/cockpit/deep-agents/sandboxes/python/docs/guide.md b/cockpit/deep-agents/sandboxes/python/docs/guide.md index f6c778446..c73d97a5d 100644 --- a/cockpit/deep-agents/sandboxes/python/docs/guide.md +++ b/cockpit/deep-agents/sandboxes/python/docs/guide.md @@ -153,7 +153,7 @@ def run_code(code: str) -> str: "exit_status": 0, }) -llm = ChatOpenAI(model="gpt-4o-mini").bind_tools([run_code]) +llm = ChatOpenAI(model="gpt-5-mini").bind_tools([run_code]) tool_node = ToolNode([run_code]) ``` diff --git a/cockpit/deep-agents/sandboxes/python/src/graph.py b/cockpit/deep-agents/sandboxes/python/src/graph.py index 04516c6a0..52ad0b902 100644 --- a/cockpit/deep-agents/sandboxes/python/src/graph.py +++ b/cockpit/deep-agents/sandboxes/python/src/graph.py @@ -71,7 +71,7 @@ def run_code(code: str) -> str: def build_sandboxes_graph(): tools = [run_code] - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True).bind_tools(tools) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True).bind_tools(tools) tool_node = ToolNode(tools) async def agent(state: SandboxesState) -> dict: diff --git a/cockpit/deep-agents/skills/python/docs/guide.md b/cockpit/deep-agents/skills/python/docs/guide.md index 50a7a40db..fd3ca9820 100644 --- a/cockpit/deep-agents/skills/python/docs/guide.md +++ b/cockpit/deep-agents/skills/python/docs/guide.md @@ -150,7 +150,7 @@ def summarize(text: str) -> str: return sentences[0] + "." if sentences else "No content." # Bind all tools to the LLM -llm = ChatOpenAI(model="gpt-4o-mini").bind_tools([calculator, word_count, summarize]) +llm = ChatOpenAI(model="gpt-5-mini").bind_tools([calculator, word_count, summarize]) ``` The agent selects which skill to call based on the user's request. `ToolNode` dispatches the call and returns the result as a `ToolMessage`. diff --git a/cockpit/deep-agents/skills/python/src/graph.py b/cockpit/deep-agents/skills/python/src/graph.py index 99fe3031a..32274232b 100644 --- a/cockpit/deep-agents/skills/python/src/graph.py +++ b/cockpit/deep-agents/skills/python/src/graph.py @@ -65,7 +65,7 @@ def summarize(text: str) -> str: def build_skills_graph(): tools = [calculator, word_count, summarize] - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True).bind_tools(tools) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True).bind_tools(tools) tool_node = ToolNode(tools) async def agent(state: SkillsState) -> dict: diff --git a/cockpit/deep-agents/subagents/python/src/graph.py b/cockpit/deep-agents/subagents/python/src/graph.py index 3084382b6..a66f73341 100644 --- a/cockpit/deep-agents/subagents/python/src/graph.py +++ b/cockpit/deep-agents/subagents/python/src/graph.py @@ -23,12 +23,12 @@ class SubagentsState(TypedDict): def build_subagents_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) @tool async def research_agent(topic: str) -> str: """Spawn a research subagent to gather information on a topic.""" - research_llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + research_llm = ChatOpenAI(model="gpt-5-mini", streaming=True) response = await research_llm.ainvoke([ SystemMessage(content="You are a research specialist. Provide concise, factual information."), {"role": "human", "content": f"Research this topic and provide key facts: {topic}"}, @@ -38,7 +38,7 @@ async def research_agent(topic: str) -> str: @tool async def analysis_agent(content: str) -> str: """Spawn an analysis subagent to analyze and synthesize information.""" - analysis_llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + analysis_llm = ChatOpenAI(model="gpt-5-mini", streaming=True) response = await analysis_llm.ainvoke([ SystemMessage(content="You are an analysis specialist. Identify patterns, draw insights, and synthesize information clearly."), {"role": "human", "content": f"Analyze this content and provide key insights: {content}"}, @@ -48,7 +48,7 @@ async def analysis_agent(content: str) -> str: @tool async def summary_agent(findings: str) -> str: """Spawn a summary subagent to produce a final coherent response.""" - summary_llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + summary_llm = ChatOpenAI(model="gpt-5-mini", streaming=True) response = await summary_llm.ainvoke([ SystemMessage(content="You are a summarization specialist. Produce clear, well-structured summaries."), {"role": "human", "content": f"Summarize these findings into a concise final answer: {findings}"}, diff --git a/cockpit/langgraph/deployment-runtime/python/src/graph.py b/cockpit/langgraph/deployment-runtime/python/src/graph.py index cb8687ec0..cd461cde0 100644 --- a/cockpit/langgraph/deployment-runtime/python/src/graph.py +++ b/cockpit/langgraph/deployment-runtime/python/src/graph.py @@ -28,7 +28,7 @@ def build_deployment_runtime_graph(): LangGraph Cloud. The assistantId in the Angular component must match the graph key in langgraph.json. """ - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: MessagesState) -> dict: """Generate a response using the full message history.""" diff --git a/cockpit/langgraph/durable-execution/python/docs/guide.md b/cockpit/langgraph/durable-execution/python/docs/guide.md index 2c3e1d759..a5b7fc705 100644 --- a/cockpit/langgraph/durable-execution/python/docs/guide.md +++ b/cockpit/langgraph/durable-execution/python/docs/guide.md @@ -124,7 +124,7 @@ class DurableState(TypedDict): checkpointer = MemorySaver() def build_durable_execution_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def analyze(state): ... # Node 1 async def plan(state): ... # Node 2 diff --git a/cockpit/langgraph/durable-execution/python/src/graph.py b/cockpit/langgraph/durable-execution/python/src/graph.py index f775b0960..b1f9d5e96 100644 --- a/cockpit/langgraph/durable-execution/python/src/graph.py +++ b/cockpit/langgraph/durable-execution/python/src/graph.py @@ -32,7 +32,7 @@ def build_durable_execution_graph(): and checkpoints state after each one. This means any node failure only requires replaying from the previous checkpoint, not the start. """ - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) system_prompt = (PROMPTS_DIR / "durable-execution.md").read_text() async def analyze(state: DurableState) -> dict: diff --git a/cockpit/langgraph/interrupts/python/docs/guide.md b/cockpit/langgraph/interrupts/python/docs/guide.md index 3befc050a..848da849e 100644 --- a/cockpit/langgraph/interrupts/python/docs/guide.md +++ b/cockpit/langgraph/interrupts/python/docs/guide.md @@ -111,7 +111,7 @@ from langgraph.types import interrupt checkpointer = MemorySaver() def build_interrupts_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: MessagesState) -> dict: response = await llm.ainvoke(state["messages"]) diff --git a/cockpit/langgraph/memory/python/docs/guide.md b/cockpit/langgraph/memory/python/docs/guide.md index df8d78797..2adc9a14b 100644 --- a/cockpit/langgraph/memory/python/docs/guide.md +++ b/cockpit/langgraph/memory/python/docs/guide.md @@ -119,7 +119,7 @@ class MemoryState(TypedDict): checkpointer = MemorySaver() def build_memory_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: MemoryState) -> dict: memory = state.get("memory", {}) diff --git a/cockpit/langgraph/memory/python/src/graph.py b/cockpit/langgraph/memory/python/src/graph.py index d23328f17..ad09d9896 100644 --- a/cockpit/langgraph/memory/python/src/graph.py +++ b/cockpit/langgraph/memory/python/src/graph.py @@ -37,8 +37,8 @@ def build_memory_graph(): This ensures every response is followed by a memory extraction pass, keeping the agent's knowledge up to date without blocking the reply. """ - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) - extractor_llm = ChatOpenAI(model="gpt-4o-mini", streaming=False) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + extractor_llm = ChatOpenAI(model="gpt-5-mini", streaming=False) async def generate(state: MemoryState) -> dict: """Generate a response using current messages and known memory.""" diff --git a/cockpit/langgraph/persistence/python/docs/guide.md b/cockpit/langgraph/persistence/python/docs/guide.md index ab7723771..d22cc38fb 100644 --- a/cockpit/langgraph/persistence/python/docs/guide.md +++ b/cockpit/langgraph/persistence/python/docs/guide.md @@ -121,7 +121,7 @@ from langgraph.checkpoint.memory import MemorySaver checkpointer = MemorySaver() def build_persistence_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: MessagesState) -> dict: response = await llm.ainvoke(state["messages"]) diff --git a/cockpit/langgraph/streaming/python/docs/guide.md b/cockpit/langgraph/streaming/python/docs/guide.md index 5533eb653..989ff85ab 100644 --- a/cockpit/langgraph/streaming/python/docs/guide.md +++ b/cockpit/langgraph/streaming/python/docs/guide.md @@ -95,7 +95,7 @@ from langgraph.graph import StateGraph, END from langchain_openai import ChatOpenAI def build_streaming_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state): response = await llm.ainvoke(state["messages"]) diff --git a/cockpit/langgraph/streaming/python/src/dashboard_graph.py b/cockpit/langgraph/streaming/python/src/dashboard_graph.py index 8294e6695..d475bfe7a 100644 --- a/cockpit/langgraph/streaming/python/src/dashboard_graph.py +++ b/cockpit/langgraph/streaming/python/src/dashboard_graph.py @@ -19,7 +19,7 @@ _PROMPT = (Path(__file__).parent.parent / "prompts" / "dashboard.md").read_text() -_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, streaming=True) +_llm = ChatOpenAI(model="gpt-5-mini", temperature=0, streaming=True) _llm_with_tools = _llm.bind_tools(ALL_TOOLS) diff --git a/cockpit/langgraph/subgraphs/python/docs/guide.md b/cockpit/langgraph/subgraphs/python/docs/guide.md index fcdeddff8..ba4e0c1b0 100644 --- a/cockpit/langgraph/subgraphs/python/docs/guide.md +++ b/cockpit/langgraph/subgraphs/python/docs/guide.md @@ -89,7 +89,7 @@ The backend uses a parent orchestrator that delegates to a compiled child subgra from langgraph.graph import StateGraph, MessagesState, END from langchain_openai import ChatOpenAI -llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) +llm = ChatOpenAI(model="gpt-5-mini", streaming=True) # Child: research subgraph async def research_node(state: MessagesState) -> dict: diff --git a/cockpit/langgraph/subgraphs/python/src/graph.py b/cockpit/langgraph/subgraphs/python/src/graph.py index 1250aeb2a..5429de284 100644 --- a/cockpit/langgraph/subgraphs/python/src/graph.py +++ b/cockpit/langgraph/subgraphs/python/src/graph.py @@ -22,7 +22,7 @@ def build_subgraphs_graph(): The parent orchestrator decides when to delegate to the research subgraph. The research subgraph runs independently and returns its results to the parent. """ - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) # ── Child: research subgraph ────────────────────────────────────────────── diff --git a/cockpit/langgraph/time-travel/python/docs/guide.md b/cockpit/langgraph/time-travel/python/docs/guide.md index a24c2bd93..6acd58509 100644 --- a/cockpit/langgraph/time-travel/python/docs/guide.md +++ b/cockpit/langgraph/time-travel/python/docs/guide.md @@ -112,7 +112,7 @@ from langgraph.checkpoint.memory import MemorySaver checkpointer = MemorySaver() def build_time_travel_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: MessagesState) -> dict: response = await llm.ainvoke(state["messages"]) diff --git a/cockpit/langgraph/time-travel/python/src/graph.py b/cockpit/langgraph/time-travel/python/src/graph.py index 05304972e..9eee3f5b8 100644 --- a/cockpit/langgraph/time-travel/python/src/graph.py +++ b/cockpit/langgraph/time-travel/python/src/graph.py @@ -25,7 +25,7 @@ def build_time_travel_graph(): producing a history of ThreadState objects that the client can replay or branch from using checkpoint IDs. """ - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: MessagesState) -> dict: """Generate a response, checkpointed for time travel.""" diff --git a/docs/superpowers/plans/2026-03-19-langsmith-deployment.md b/docs/superpowers/plans/2026-03-19-langsmith-deployment.md index 74ddf2d74..83ea7f8a3 100644 --- a/docs/superpowers/plans/2026-03-19-langsmith-deployment.md +++ b/docs/superpowers/plans/2026-03-19-langsmith-deployment.md @@ -20,7 +20,7 @@ Website (Vercel) └─ NEXT_PUBLIC_LANGGRAPH_URL └─ LangGraph Cloud (LangSmith) └─ chat_agent graph (Python) - └─ ChatOpenAI (gpt-4o-mini) + └─ ChatOpenAI (gpt-5-mini) ``` --- @@ -141,7 +141,7 @@ From the LangSmith dashboard → Deployments → your deployment → Environment | Variable | Value | |---|---| | `OPENAI_API_KEY` | Your OpenAI key | -| `OPENAI_MODEL` | `gpt-4o-mini` (default) or preferred model | +| `OPENAI_MODEL` | `gpt-5-mini` (default) or preferred model | | `LANGSMITH_TRACING` | `true` (optional, for production tracing) | | `LANGSMITH_PROJECT` | `angular-example` | diff --git a/docs/superpowers/plans/2026-04-03-cockpit-example-harness.md b/docs/superpowers/plans/2026-04-03-cockpit-example-harness.md index 2b0d6b711..1b73fc318 100644 --- a/docs/superpowers/plans/2026-04-03-cockpit-example-harness.md +++ b/docs/superpowers/plans/2026-04-03-cockpit-example-harness.md @@ -1077,7 +1077,7 @@ def build_streaming_graph() -> StateGraph: Returns: A compiled StateGraph ready for invocation """ - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: StreamingState) -> dict: """ diff --git a/docs/superpowers/plans/2026-04-03-docs-mode-redesign.md b/docs/superpowers/plans/2026-04-03-docs-mode-redesign.md index 36baffb78..866827385 100644 --- a/docs/superpowers/plans/2026-04-03-docs-mode-redesign.md +++ b/docs/superpowers/plans/2026-04-03-docs-mode-redesign.md @@ -779,7 +779,7 @@ from langgraph.graph import StateGraph, END from langchain_openai import ChatOpenAI def build_streaming_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state): response = await llm.ainvoke(state["messages"]) diff --git a/docs/superpowers/plans/2026-04-03-narrative-docs.md b/docs/superpowers/plans/2026-04-03-narrative-docs.md index f40b31b82..a01b1a991 100644 --- a/docs/superpowers/plans/2026-04-03-narrative-docs.md +++ b/docs/superpowers/plans/2026-04-03-narrative-docs.md @@ -514,7 +514,7 @@ from langgraph.graph import StateGraph, END from langchain_openai import ChatOpenAI def build_streaming_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state): response = await llm.ainvoke(state["messages"]) diff --git a/docs/superpowers/plans/2026-04-08-generative-ui-spike.md b/docs/superpowers/plans/2026-04-08-generative-ui-spike.md index 9746a7877..c3d21975c 100644 --- a/docs/superpowers/plans/2026-04-08-generative-ui-spike.md +++ b/docs/superpowers/plans/2026-04-08-generative-ui-spike.md @@ -176,7 +176,7 @@ PROMPTS_DIR = Path(__file__).parent.parent / "prompts" def build_generative_ui_graph(): - llm = ChatOpenAI(model="gpt-4o-mini", streaming=True) + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: MessagesState) -> dict: system_prompt = (PROMPTS_DIR / "generative-ui.md").read_text() diff --git a/docs/superpowers/plans/2026-05-03-chat-reasoning-and-tool-call-templates.md b/docs/superpowers/plans/2026-05-03-chat-reasoning-and-tool-call-templates.md new file mode 100644 index 000000000..9c195efb5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-chat-reasoning-and-tool-call-templates.md @@ -0,0 +1,3629 @@ +# Chat Reasoning + Tool-Call Templates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface model reasoning content as a first-class collapsible pill above the assistant response, and turn tool-call rendering into a CopilotKit-style extension surface via a `chatToolCallTemplate` directive while keeping a polished default that auto-collapses completed cards and groups sequential same-name calls. + +**Architecture:** One new primitive (``), one new directive (`chatToolCallTemplate`), augmentations to two existing primitives (``, ``), two new optional `Message` fields (`reasoning`, `reasoningDurationMs`) populated by both adapters from provider-agnostic sources (LangGraph complex-content reasoning blocks and AG-UI `REASONING_MESSAGE_*` events). Single-PR shipment across `@ngaf/chat`, `@ngaf/langgraph`, and `@ngaf/ag-ui`. + +**Tech Stack:** Angular 21 standalone + signals + OnPush; vitest for library tests; nx monorepo build (`npx nx build `, `npx nx test `); LangGraph SDK + AG-UI client for adapter event streams; marked + sanitized innerHTML for markdown rendering; @ngaf/chat as the shared contract surface between adapters. + +**Reference spec:** `docs/superpowers/specs/2026-05-03-chat-reasoning-and-tool-call-templates-design.md` + +**Hard constraint:** Never reference any chat-UI library this work was inspired by — no `copilotkit` / `chatgpt` / `chatbot-kit` / similar references in code, comments, commits, PR bodies, or docs. Aesthetic and extensibility patterns are independently arrived at. + +--- + +## Phase 0: Branch setup + baseline + +### Task 0.1: Branch from main + +**Files:** none (git only) + +- [ ] **Step 1: Confirm PR #191 is merged + main is up-to-date** + +```bash +gh pr view 191 --json state --jq '.state' +git fetch origin main +git log --oneline origin/main | head -3 +``` + +Expected: state is `MERGED` and the latest origin/main commit has the streaming/markdown + model-picker work landed. + +- [ ] **Step 2: Create the implementation branch** + +```bash +git checkout -b claude/chat-reasoning-and-tool-call-templates origin/main +``` + +Expected: switched to a fresh branch off main with a clean working tree (`git status` shows nothing). + +- [ ] **Step 3: Verify clean baseline build** + +```bash +npx nx run-many --target=build --projects=licensing,render,chat,langgraph,ag-ui 2>&1 | tail -5 +``` + +Expected: all five builds succeed. If any fail, stop and report the error before starting Phase 1. + +- [ ] **Step 4: Verify clean baseline tests** + +```bash +npx nx run-many --target=test --projects=chat,langgraph,ag-ui 2>&1 | tail -10 +``` + +Expected: all three test suites pass. The number of passing tests on this baseline is the floor — every later commit must keep them passing. + +--- + +## Phase 1: Foundation — `Message` fields + `formatDuration` utility + +### Task 1.1: Add `reasoning` + `reasoningDurationMs` to `Message` + +**Files:** +- Modify: `libs/chat/src/lib/agent/message.ts` +- Test: `libs/chat/src/lib/agent/message.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Open `libs/chat/src/lib/agent/message.spec.ts` and append (after the existing tests): + +```typescript +describe('Message — reasoning fields', () => { + it('accepts an optional reasoning string', () => { + const m: Message = { + id: 'a', + role: 'assistant', + content: 'hello', + reasoning: 'first I thought about it', + }; + expect(m.reasoning).toBe('first I thought about it'); + }); + + it('accepts an optional reasoningDurationMs number', () => { + const m: Message = { + id: 'a', + role: 'assistant', + content: 'hello', + reasoning: 'first I thought about it', + reasoningDurationMs: 1234, + }; + expect(m.reasoningDurationMs).toBe(1234); + }); + + it('treats both reasoning fields as optional', () => { + const m: Message = { id: 'a', role: 'assistant', content: 'hello' }; + expect(m.reasoning).toBeUndefined(); + expect(m.reasoningDurationMs).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +npx vitest run libs/chat/src/lib/agent/message.spec.ts 2>&1 | tail -10 +``` + +Expected: TypeScript compile error — `Object literal may only specify known properties, and 'reasoning' does not exist in type 'Message'.` + +- [ ] **Step 3: Add the fields to `Message`** + +Replace the `Message` interface in `libs/chat/src/lib/agent/message.ts`: + +```typescript +export interface Message { + id: string; + role: Role; + /** Plain text, or a list of structured content blocks. */ + content: string | ContentBlock[]; + /** Present when role === 'tool'. */ + toolCallId?: string; + /** Optional display/author name. */ + name?: string; + /** + * Reasoning text emitted by the model before/alongside the visible + * response. Populated by adapters from {type:'reasoning'} or + * {type:'thinking'} content blocks (LangGraph) or REASONING_MESSAGE_* + * events (AG-UI). Always a plain string — provider-specific shape + * (encrypted blocks, multi-step summaries) is absorbed by the adapter + * and not surfaced here. + */ + reasoning?: string; + /** + * Wall-clock duration of the reasoning phase in milliseconds. + * Populated by the adapter when both start (first reasoning chunk) and + * end (first response-text chunk, or final canonical message) are + * known. Undefined when reasoning timing isn't available. + */ + reasoningDurationMs?: number; + /** Runtime-specific extras; do not rely on shape in portable code. */ + extra?: Record; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +npx vitest run libs/chat/src/lib/agent/message.spec.ts 2>&1 | tail -5 +``` + +Expected: all `Message — reasoning fields` tests pass; existing tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/agent/message.ts libs/chat/src/lib/agent/message.spec.ts +git commit -m "feat(chat): add Message.reasoning + Message.reasoningDurationMs + +Optional fields on the shared Message contract. Adapters populate them +from provider-agnostic sources (LangGraph reasoning/thinking content +blocks, AG-UI REASONING_MESSAGE_* events). UI primitives consume the +fields without provider-specific knowledge. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 1.2: Add `formatDuration` utility + +**Files:** +- Create: `libs/chat/src/lib/utils/format-duration.ts` +- Create: `libs/chat/src/lib/utils/format-duration.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `libs/chat/src/lib/utils/format-duration.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { formatDuration } from './format-duration'; + +describe('formatDuration', () => { + it('renders sub-second durations as "<1s"', () => { + expect(formatDuration(0)).toBe('<1s'); + expect(formatDuration(500)).toBe('<1s'); + expect(formatDuration(999)).toBe('<1s'); + }); + + it('renders sub-minute durations in seconds', () => { + expect(formatDuration(1000)).toBe('1s'); + expect(formatDuration(4000)).toBe('4s'); + expect(formatDuration(59_000)).toBe('59s'); + expect(formatDuration(59_999)).toBe('59s'); + }); + + it('renders minute-or-greater durations as "Nm Ms"', () => { + expect(formatDuration(60_000)).toBe('1m 0s'); + expect(formatDuration(72_000)).toBe('1m 12s'); + expect(formatDuration(125_000)).toBe('2m 5s'); + expect(formatDuration(3_600_000)).toBe('60m 0s'); + }); + + it('clamps negative inputs to "<1s"', () => { + expect(formatDuration(-1)).toBe('<1s'); + expect(formatDuration(-1000)).toBe('<1s'); + }); + + it('handles non-finite inputs by returning "<1s"', () => { + expect(formatDuration(Number.NaN)).toBe('<1s'); + expect(formatDuration(Number.POSITIVE_INFINITY)).toBe('<1s'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +npx vitest run libs/chat/src/lib/utils/format-duration.spec.ts 2>&1 | tail -5 +``` + +Expected: `Cannot find module './format-duration'`. + +- [ ] **Step 3: Implement the utility** + +Create `libs/chat/src/lib/utils/format-duration.ts`: + +```typescript +// SPDX-License-Identifier: MIT + +/** + * Render a millisecond duration as a human-readable label suitable for + * the chat-reasoning "Thought for Ns" pill. + * + * - <1 s → "<1s" + * - 1–59 s → "Ns" (e.g. "4s") + * - ≥60 s → "Nm Ms" (e.g. "1m 12s", "60m 0s") + * + * Negative or non-finite inputs collapse to "<1s" so a corrupted timing + * map never produces noisy output. + */ +export function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms < 1000) return '<1s'; + const totalSeconds = Math.floor(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds - minutes * 60; + return `${minutes}m ${seconds}s`; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +npx vitest run libs/chat/src/lib/utils/format-duration.spec.ts 2>&1 | tail -5 +``` + +Expected: all 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/utils/format-duration.ts libs/chat/src/lib/utils/format-duration.spec.ts +git commit -m "feat(chat): add formatDuration utility + +Renders millisecond durations as compact human-readable labels: +<1s, Ns, Nm Ms. Powers the chat-reasoning 'Thought for Ns' pill. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 2: New primitive — `` (TDD) + +### Task 2.1: Write the chat-reasoning component spec + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts` + +- [ ] **Step 1: Write the spec** + +Create `libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ChatReasoningComponent } from './chat-reasoning.component'; + +@Component({ + standalone: true, + imports: [ChatReasoningComponent], + template: ` + + `, +}) +class HostComponent { + content = signal('I considered the problem.'); + streaming = signal(false); + durationMs = signal(undefined); + defaultExpanded = signal(false); +} + +function makeFixture() { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + return fixture; +} + +function getEl(fixture: ReturnType): HTMLElement { + return fixture.nativeElement.querySelector('chat-reasoning'); +} + +function getHeader(fixture: ReturnType): HTMLButtonElement { + return fixture.nativeElement.querySelector('chat-reasoning button.chat-reasoning__header'); +} + +function getLabelText(fixture: ReturnType): string { + return fixture.nativeElement.querySelector('chat-reasoning .chat-reasoning__label')?.textContent?.trim() ?? ''; +} + +describe('ChatReasoningComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + }); + + it('hides itself when content is empty', () => { + const fixture = makeFixture(); + fixture.componentInstance.content.set(''); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-has-content')).toBe('false'); + }); + + it('shows itself when content is non-empty', () => { + const fixture = makeFixture(); + expect(getEl(fixture).getAttribute('data-has-content')).toBe('true'); + }); + + it('renders "Thinking…" while streaming', () => { + const fixture = makeFixture(); + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); + expect(getLabelText(fixture)).toContain('Thinking'); + }); + + it('renders "Thought for Ns" when idle with durationMs', () => { + const fixture = makeFixture(); + fixture.componentInstance.streaming.set(false); + fixture.componentInstance.durationMs.set(4000); + fixture.detectChanges(); + expect(getLabelText(fixture)).toContain('Thought for 4s'); + }); + + it('renders "Show reasoning" when idle without durationMs', () => { + const fixture = makeFixture(); + fixture.componentInstance.streaming.set(false); + fixture.componentInstance.durationMs.set(undefined); + fixture.detectChanges(); + expect(getLabelText(fixture)).toContain('Show reasoning'); + }); + + it('starts collapsed by default', () => { + const fixture = makeFixture(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('false'); + }); + + it('starts expanded when defaultExpanded=true', () => { + const fixture = makeFixture(); + fixture.componentInstance.defaultExpanded.set(true); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + }); + + it('force-expands while streaming', () => { + const fixture = makeFixture(); + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + }); + + it('toggles open and closed on header click', () => { + const fixture = makeFixture(); + const header = getHeader(fixture); + header.click(); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + header.click(); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('false'); + }); + + it('preserves user choice across isStreaming transitions', () => { + const fixture = makeFixture(); + // User opens manually + getHeader(fixture).click(); + fixture.detectChanges(); + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + + // Streaming completes (isStreaming false → still true after transition because user opened) + fixture.componentInstance.streaming.set(true); + fixture.detectChanges(); + fixture.componentInstance.streaming.set(false); + fixture.detectChanges(); + + expect(getEl(fixture).getAttribute('data-expanded')).toBe('true'); + }); + + it('renders the content body inside chat-streaming-md when expanded', () => { + const fixture = makeFixture(); + fixture.componentInstance.defaultExpanded.set(true); + fixture.detectChanges(); + const md = fixture.nativeElement.querySelector('chat-reasoning chat-streaming-md'); + expect(md).not.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the spec to verify it fails** + +```bash +npx vitest run libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts 2>&1 | tail -5 +``` + +Expected: `Cannot find module './chat-reasoning.component'`. + +### Task 2.2: Implement the chat-reasoning styles + +**Files:** +- Create: `libs/chat/src/lib/styles/chat-reasoning.styles.ts` + +- [ ] **Step 1: Write the styles** + +Create `libs/chat/src/lib/styles/chat-reasoning.styles.ts`: + +```typescript +// libs/chat/src/lib/styles/chat-reasoning.styles.ts +// SPDX-License-Identifier: MIT +// +// Style block for the chat-reasoning primitive. Pill-shaped header with +// a chevron + label; expanded body sits below the header with a thin +// left border (matches the blockquote pattern in chat-markdown.styles). +// Muted text colors throughout so reasoning content recedes visually +// next to the response. +export const CHAT_REASONING_STYLES = ` + :host { display: block; margin: 0 0 0.5rem; } + :host([data-has-content="false"]) { display: none; } + + .chat-reasoning__header { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 4px 10px; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 9999px; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-xs); + font-family: inherit; + cursor: pointer; + line-height: 1.2; + } + .chat-reasoning__header:hover { color: var(--ngaf-chat-text); } + + .chat-reasoning__chevron { + width: 10px; + height: 10px; + transition: transform 120ms ease; + } + :host([data-expanded="true"]) .chat-reasoning__chevron { transform: rotate(90deg); } + + .chat-reasoning__pulse { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ngaf-chat-text-muted); + animation: chat-reasoning-pulse 1.2s ease-in-out infinite; + } + @keyframes chat-reasoning-pulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 1; } + } + + .chat-reasoning__body { + margin-top: 0.5rem; + padding-left: 12px; + border-left: 2px solid var(--ngaf-chat-separator); + color: var(--ngaf-chat-text-muted); + } + .chat-reasoning__body chat-streaming-md { font-size: 0.95em; } +`; +``` + +- [ ] **Step 2: Commit (styles only — component fails build until 2.3)** + +```bash +git add libs/chat/src/lib/styles/chat-reasoning.styles.ts +git commit -m "feat(chat): chat-reasoning styles + +Pill-shaped header with chevron + animated pulse dot for the streaming +state, expanded body with thin left border (matches the blockquote +pattern). Muted text throughout so reasoning content recedes next to +the response. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.3: Implement the chat-reasoning component + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts` + +- [ ] **Step 1: Write the component** + +Create `libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.ts +// SPDX-License-Identifier: MIT +import { + Component, ChangeDetectionStrategy, + computed, effect, input, signal, +} from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_REASONING_STYLES } from '../../styles/chat-reasoning.styles'; +import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component'; +import { formatDuration } from '../../utils/format-duration'; + +/** + * Renders an assistant's reasoning content as a compact pill that + * expands to reveal the underlying text. Three visual states: + * + * - Streaming: pill shows "Thinking…" with a pulsing dot; auto-expanded + * so the user sees reasoning stream in real time. + * - Idle, with durationMs known: pill shows "Thought for {duration}"; + * collapsed by default, expand on click. + * - Idle, no duration: pill shows "Show reasoning"; collapsed by default. + * + * The body re-uses chat-streaming-md so reasoning content gets the same + * markdown rendering pipeline as the visible response (lists, code, + * step labels often appear in reasoning output). + * + * Internal state: a tristate "expanded" — null means follow auto state- + * driven logic (force-expand on isStreaming, otherwise honor + * defaultExpanded), boolean is a manual user choice that wins for the + * lifetime of the instance. + */ +@Component({ + selector: 'chat-reasoning', + standalone: true, + imports: [ChatStreamingMdComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_REASONING_STYLES], + host: { + '[attr.data-has-content]': 'hasContent()', + '[attr.data-expanded]': 'expandedStr()', + '[attr.data-streaming]': 'isStreaming()', + }, + template: ` + + @if (expanded()) { +
+ +
+ } + `, +}) +export class ChatReasoningComponent { + readonly content = input.required(); + readonly isStreaming = input(false); + readonly durationMs = input(undefined); + readonly label = input(undefined); + readonly defaultExpanded = input(false); + + readonly hasContent = computed(() => (this.content() ?? '').length > 0); + + /** null = follow auto logic (streaming → expanded, else defaultExpanded). */ + private readonly _expandedOverride = signal(null); + + readonly expanded = computed(() => { + const override = this._expandedOverride(); + if (override !== null) return override; + if (this.isStreaming()) return true; + return this.defaultExpanded(); + }); + + readonly expandedStr = computed(() => String(this.expanded())); + + readonly resolvedLabel = computed(() => { + const explicit = this.label(); + if (explicit) return explicit; + if (this.isStreaming()) return 'Thinking…'; + const ms = this.durationMs(); + if (typeof ms === 'number') return `Thought for ${formatDuration(ms)}`; + return 'Show reasoning'; + }); + + constructor() { + // Reset the override when the component re-enters streaming state + // (e.g. follow-up turn that re-uses this instance) so the auto + // force-expand logic takes over again. + let prevStreaming = false; + effect(() => { + const streaming = this.isStreaming(); + if (!prevStreaming && streaming) { + this._expandedOverride.set(null); + } + prevStreaming = streaming; + }); + } + + toggle(): void { + this._expandedOverride.set(!this.expanded()); + } +} +``` + +- [ ] **Step 2: Run the spec to verify it passes** + +```bash +npx vitest run libs/chat/src/lib/primitives/chat-reasoning/chat-reasoning.component.spec.ts 2>&1 | tail -10 +``` + +Expected: 11/11 ChatReasoningComponent tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-reasoning/ +git commit -m "feat(chat): chat-reasoning primitive + +Renders model reasoning content as a compact pill. Three visual states +(streaming with pulse + auto-expand, idle with 'Thought for Ns', idle +with 'Show reasoning' fallback). User toggle wins over auto logic for +the lifetime of the instance; auto logic resumes when streaming +re-engages on a follow-up turn. Body re-uses chat-streaming-md so +markdown in reasoning output renders consistently with the response. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.4: Export from public API + +**Files:** +- Modify: `libs/chat/src/public-api.ts` + +- [ ] **Step 1: Add the exports** + +In `libs/chat/src/public-api.ts`, locate the section that exports primitives (around line 41, just after `ChatTraceComponent`) and add: + +```typescript +export { ChatReasoningComponent } from './lib/primitives/chat-reasoning/chat-reasoning.component'; +``` + +In the section that exports utilities (find by searching for `formatDuration` or add near the streaming utils block), add: + +```typescript +export { formatDuration } from './lib/utils/format-duration'; +``` + +- [ ] **Step 2: Build the chat library to verify exports compile** + +```bash +npx nx build chat 2>&1 | tail -3 +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/public-api.ts +git commit -m "feat(chat): export ChatReasoningComponent + formatDuration + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 3: New directive — `chatToolCallTemplate` (TDD) + +### Task 3.1: Write the directive spec + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts` + +- [ ] **Step 1: Write the spec** + +Create `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, contentChildren, TemplateRef } from '@angular/core'; +import { ChatToolCallTemplateDirective } from './chat-tool-call-template.directive'; + +@Component({ + standalone: true, + imports: [ChatToolCallTemplateDirective], + template: ` + + {{ call.name }} + + + {{ call.name }} / {{ status }} + + + {{ call.name }} + + `, +}) +class HostComponent { + readonly templates = contentChildren(ChatToolCallTemplateDirective); +} + +describe('ChatToolCallTemplateDirective', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + }); + + it('exposes the tool name as `name`', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + // contentChildren is empty in this self-hosted test (no projection happening) + // so directly query directives via DI on the host element. Easier: use + // the queryAll API on the test-bed debug element. + const directives = fixture.debugElement.queryAll( + (e) => e.injector.get(ChatToolCallTemplateDirective, null) !== null, + ).map((e) => e.injector.get(ChatToolCallTemplateDirective)); + expect(directives.map((d) => d.name())).toEqual(['search_web', 'generate_image', '*']); + }); + + it('captures the TemplateRef', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const directive = fixture.debugElement.query( + (e) => e.injector.get(ChatToolCallTemplateDirective, null) !== null, + ).injector.get(ChatToolCallTemplateDirective); + expect(directive.templateRef).toBeInstanceOf(TemplateRef); + }); +}); +``` + +- [ ] **Step 2: Run the spec to verify it fails** + +```bash +npx vitest run libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts 2>&1 | tail -5 +``` + +Expected: `Cannot find module './chat-tool-call-template.directive'`. + +### Task 3.2: Implement the directive + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.ts` + +- [ ] **Step 1: Write the directive** + +Create `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.ts +// SPDX-License-Identifier: MIT +import { Directive, TemplateRef, inject, input } from '@angular/core'; +import type { ToolCall, ToolCallStatus } from '../../agent'; + +/** + * Template-context surface available to a per-tool template. The first + * argument is the ToolCall itself (let-call); status is exposed as a + * named context property (let-status="status"). + */ +export interface ChatToolCallTemplateContext { + $implicit: ToolCall; + status: ToolCallStatus; +} + +/** + * Registers a per-tool-name template inside . The + * primitive collects all directive instances via contentChildren() and + * dispatches incoming calls by their `name` field. A literal "*" name + * registers a wildcard catch-all that handles any tool name without a + * specific template registered. + * + * Usage: + * + * + * + * + * + * + * + * + * + */ +@Directive({ + selector: '[chatToolCallTemplate]', + standalone: true, +}) +export class ChatToolCallTemplateDirective { + /** The tool name this template handles, or "*" for the wildcard catch-all. */ + readonly name = input.required({ alias: 'chatToolCallTemplate' }); + readonly templateRef = inject(TemplateRef); +} +``` + +- [ ] **Step 2: Run the spec to verify it passes** + +```bash +npx vitest run libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts 2>&1 | tail -5 +``` + +Expected: 2/2 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.ts libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-call-template.directive.spec.ts +git commit -m "feat(chat): chatToolCallTemplate directive + +Per-tool-name template registry consumed by . A '*' +wildcard registers a catch-all for any unmapped tool name. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 3.3: Export from public API + +**Files:** +- Modify: `libs/chat/src/public-api.ts` + +- [ ] **Step 1: Add the exports** + +After the `ChatToolCallsComponent` export (around line 49), add: + +```typescript +export { ChatToolCallTemplateDirective } from './lib/primitives/chat-tool-calls/chat-tool-call-template.directive'; +export type { ChatToolCallTemplateContext } from './lib/primitives/chat-tool-calls/chat-tool-call-template.directive'; +``` + +- [ ] **Step 2: Build to verify** + +```bash +npx nx build chat 2>&1 | tail -3 +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/public-api.ts +git commit -m "feat(chat): export ChatToolCallTemplateDirective + context type + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 4: Augment `` — grouping + per-tool template registry + +### Task 4.1: Write the augmented spec + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts` + +- [ ] **Step 1: Append new tests** + +Append the following block to `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts` (after the last existing `describe` block): + +```typescript +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { ChatToolCallsComponent } from './chat-tool-calls.component'; +import { ChatToolCallTemplateDirective } from './chat-tool-call-template.directive'; + +@Component({ + standalone: true, + imports: [ChatToolCallsComponent, ChatToolCallTemplateDirective], + template: ` + + @if (registerSearchWeb) { + + {{ call.name }}-{{ call.id }} + + } + @if (registerWildcard) { + + {{ call.name }}-{{ call.id }} + + } + + `, +}) +class GroupingHost { + agent: any; + grouping: 'auto' | 'none' = 'auto'; + registerSearchWeb = false; + registerWildcard = false; +} + +import { mockAgent } from '../../testing/mock-agent'; + +describe('ChatToolCallsComponent — grouping + per-tool templates', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [GroupingHost] }); + }); + + it('groups three sequential search_web calls into one strip', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete', result: 'r' }, + { id: 'b', name: 'search_web', args: {}, status: 'complete', result: 'r' }, + { id: 'c', name: 'search_web', args: {}, status: 'complete', result: 'r' }, + ], + }); + fixture.detectChanges(); + const strips = fixture.nativeElement.querySelectorAll('[data-group="true"]'); + expect(strips.length).toBe(1); + expect(strips[0].textContent).toContain('Searched 3'); + }); + + it('does not group when names differ', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete' }, + { id: 'b', name: 'read_file', args: {}, status: 'complete' }, + { id: 'c', name: 'search_web', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + const strips = fixture.nativeElement.querySelectorAll('[data-group="true"]'); + expect(strips.length).toBe(0); + const cards = fixture.nativeElement.querySelectorAll('chat-tool-call-card'); + expect(cards.length).toBe(3); + }); + + it('does not group when [grouping]="none"', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.grouping = 'none'; + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete' }, + { id: 'b', name: 'search_web', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + const strips = fixture.nativeElement.querySelectorAll('[data-group="true"]'); + expect(strips.length).toBe(0); + }); + + it('routes each call through a per-tool template when registered', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.registerSearchWeb = true; + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete' }, + { id: 'b', name: 'search_web', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + const tplNodes = fixture.nativeElement.querySelectorAll('[data-tpl="search_web"]'); + expect(tplNodes.length).toBe(2); + // Per-tool template wins — no strip and no default cards. + expect(fixture.nativeElement.querySelectorAll('[data-group="true"]').length).toBe(0); + expect(fixture.nativeElement.querySelectorAll('chat-tool-call-card').length).toBe(0); + }); + + it('falls back to wildcard "*" template when no per-tool template matches', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.registerWildcard = true; + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'read_file', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('[data-tpl="wildcard"]').length).toBe(1); + expect(fixture.nativeElement.querySelectorAll('chat-tool-call-card').length).toBe(0); + }); + + it('per-tool template wins over wildcard for matching name', () => { + const fixture = TestBed.createComponent(GroupingHost); + fixture.componentInstance.registerSearchWeb = true; + fixture.componentInstance.registerWildcard = true; + fixture.componentInstance.agent = mockAgent({ + toolCalls: [ + { id: 'a', name: 'search_web', args: {}, status: 'complete' }, + { id: 'b', name: 'read_file', args: {}, status: 'complete' }, + ], + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('[data-tpl="search_web"]').length).toBe(1); + expect(fixture.nativeElement.querySelectorAll('[data-tpl="wildcard"]').length).toBe(1); + }); +}); + +describe('summarize-group label registry', () => { + // Imported at runtime to avoid bundling mismatch + let summarize: typeof import('./group-summary').summarizeGroup; + beforeEach(async () => { + summarize = (await import('./group-summary')).summarizeGroup; + }); + + it('uses "Searched N sites" for search_*', () => { + expect(summarize('search_web', 5)).toBe('Searched 5 sites'); + expect(summarize('search_files', 1)).toBe('Searched 1 site'); + }); + + it('uses "Generated N items" for generate_*', () => { + expect(summarize('generate_image', 3)).toBe('Generated 3 items'); + }); + + it('falls back to "Called {name} N times"', () => { + expect(summarize('foo', 4)).toBe('Called foo 4 times'); + }); +}); +``` + +- [ ] **Step 2: Run the spec to verify it fails** + +```bash +npx vitest run libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts 2>&1 | tail -5 +``` + +Expected: failures because `[grouping]` input doesn't exist on the component, and `./group-summary` module doesn't exist. + +### Task 4.2: Implement `group-summary.ts` + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-tool-calls/group-summary.ts` + +- [ ] **Step 1: Write the helper** + +Create `libs/chat/src/lib/primitives/chat-tool-calls/group-summary.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-tool-calls/group-summary.ts +// SPDX-License-Identifier: MIT + +/** + * Default summary text for a group of N consecutive same-name tool calls. + * Recognizes a small set of common tool-name prefixes; falls back to a + * generic "Called {name} N times" otherwise. + * + * Consumers can override the registry per-instance via the + * `[groupSummary]` input on . + */ +export function summarizeGroup(name: string, count: number): string { + const noun = nounForPrefix(name); + if (noun) return `${noun.verb} ${count} ${pluralize(noun.singular, count)}`; + return `Called ${name} ${count} ${count === 1 ? 'time' : 'times'}`; +} + +interface NounEntry { verb: string; singular: string } + +function nounForPrefix(name: string): NounEntry | null { + if (name.startsWith('search_')) return { verb: 'Searched', singular: 'site' }; + if (name.startsWith('generate_')) return { verb: 'Generated', singular: 'item' }; + if (name.startsWith('read_')) return { verb: 'Read', singular: 'file' }; + if (name.startsWith('write_')) return { verb: 'Wrote', singular: 'file' }; + if (name.startsWith('list_')) return { verb: 'Listed', singular: 'item' }; + return null; +} + +function pluralize(word: string, count: number): string { + return count === 1 ? word : `${word}s`; +} +``` + +- [ ] **Step 2: Run the summarizeGroup tests** + +```bash +npx vitest run libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts -t "summarize-group" 2>&1 | tail -5 +``` + +Expected: the 3 `summarize-group label registry` tests pass. + +### Task 4.3: Augment the chat-tool-calls component + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts` + +- [ ] **Step 1: Replace the component** + +Replace the entire contents of `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts`: + +```typescript +// libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts +// SPDX-License-Identifier: MIT +import { + Component, ChangeDetectionStrategy, + computed, contentChildren, input, signal, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Agent, Message, ToolCall } from '../../agent'; +import { ChatToolCallCardComponent, type ToolCallInfo } from '../../compositions/chat-tool-call-card/chat-tool-call-card.component'; +import { ChatToolCallTemplateDirective } from './chat-tool-call-template.directive'; +import { summarizeGroup as defaultSummarizeGroup } from './group-summary'; + +interface Group { + name: string; + calls: ToolCall[]; + templateRef?: ChatToolCallTemplateDirective; +} + +@Component({ + selector: 'chat-tool-calls', + standalone: true, + imports: [NgTemplateOutlet, ChatToolCallCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [` + :host { display: block; } + .ctc__group { + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-card); + margin: 0 0 8px; + } + .ctc__group-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 8px 12px; + background: transparent; + border: 0; + font: inherit; + color: var(--ngaf-chat-text); + cursor: pointer; + text-align: left; + } + .ctc__group-chevron { + width: 10px; height: 10px; + transition: transform 120ms ease; + } + .ctc__group[data-expanded="true"] .ctc__group-chevron { transform: rotate(90deg); } + .ctc__group-body { + padding: 0 12px 8px; + border-top: 1px solid var(--ngaf-chat-separator); + } + `], + template: ` + @for (group of groups(); track $index) { + @if (group.calls.length > 1 && !group.templateRef) { + + @let expanded = expandedGroups().has($index); +
+ + @if (expanded) { +
+ @for (tc of group.calls; track tc.id) { + + } +
+ } +
+ } @else if (group.templateRef) { + + @for (tc of group.calls; track tc.id) { + + } + } @else { + + @for (tc of group.calls; track tc.id) { + + } + } + } + `, +}) +export class ChatToolCallsComponent { + readonly agent = input.required(); + readonly message = input(undefined); + readonly grouping = input<'auto' | 'none'>('auto'); + readonly groupSummary = input<((name: string, count: number) => string) | undefined>(undefined); + + /** Per-tool-name + wildcard templates registered as content children. */ + readonly templates = contentChildren(ChatToolCallTemplateDirective); + + private readonly templateRegistry = computed(() => { + const map = new Map(); + for (const t of this.templates()) { + map.set(t.name(), t); + } + return map; + }); + + readonly toolCalls = computed((): ToolCall[] => { + const msg = this.message(); + if (msg && msg.role === 'assistant' && Array.isArray(msg.content)) { + const blocks = msg.content.filter((b) => b.type === 'tool_use'); + const all = this.agent().toolCalls(); + return blocks.map(b => all.find(tc => tc.id === b.id)).filter((x): x is ToolCall => !!x); + } + return this.agent().toolCalls(); + }); + + readonly groups = computed((): Group[] => { + const calls = this.toolCalls(); + const groupingMode = this.grouping(); + const registry = this.templateRegistry(); + const wildcard = registry.get('*'); + const out: Group[] = []; + for (const tc of calls) { + const tpl = registry.get(tc.name) ?? wildcard; + const last = out[out.length - 1]; + const sameName = last && last.name === tc.name; + const canGroup = groupingMode === 'auto' && sameName; + if (canGroup) { + last.calls.push(tc); + // Re-resolve template per-group: if any call has a specific template, the group uses it. + if (!last.templateRef && tpl) last.templateRef = tpl; + } else { + out.push({ name: tc.name, calls: [tc], templateRef: tpl }); + } + } + return out; + }); + + private readonly _expandedGroups = signal(new Set()); + readonly expandedGroups = this._expandedGroups.asReadonly(); + + toggleGroup(index: number): void { + this._expandedGroups.update((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); else next.add(index); + return next; + }); + } + + protected summarize(name: string, count: number): string { + return (this.groupSummary() ?? defaultSummarizeGroup)(name, count); + } + + protected toToolCallInfo(tc: ToolCall): ToolCallInfo { + return { id: tc.id, name: tc.name, args: tc.args, result: tc.result, status: tc.status }; + } +} +``` + +- [ ] **Step 2: Run the chat-tool-calls spec to verify all tests pass** + +```bash +npx vitest run libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts 2>&1 | tail -10 +``` + +Expected: all existing tests + the new 6 grouping/template tests + the 3 summarize-group tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-tool-calls/ +git commit -m "feat(chat): chat-tool-calls grouping + per-tool template registry + +Sequential same-name tool calls auto-group into a collapsible strip +with a sensible default summary ('Searched N sites'). Consumers can +register per-tool-name templates via chatToolCallTemplate to fully +replace the default card UX, plus a '*' wildcard for any unmapped +name. [grouping]='none' opts out of the auto-collapse behavior; +[groupSummary] lets callers override the default registry. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 5: Augment `` — status pill + default-collapsed + +### Task 5.1: Update `ToolCallInfo` to include status + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts` (already updated in Task 4.3 to pass `status` through `toToolCallInfo`, verify) + +- [ ] **Step 1: Confirm Task 4.3 is in place** + +```bash +grep -n "status: tc.status" libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts +``` + +Expected: matches the line in `toToolCallInfo`. If not, return to Task 4.3. + +### Task 5.2: Write the augmented card spec + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts` + +- [ ] **Step 1: Write the spec** + +Create `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ChatToolCallCardComponent, type ToolCallInfo } from './chat-tool-call-card.component'; + +@Component({ + standalone: true, + imports: [ChatToolCallCardComponent], + template: ``, +}) +class HostComponent { + tc = signal({ id: '1', name: 'search', args: {}, status: 'running' }); + defaultCollapsed = signal(true); +} + +function getStatusPill(fixture: any): HTMLElement { + return fixture.nativeElement.querySelector('chat-tool-call-card .tcc__pill'); +} + +function getCardExpanded(fixture: any): boolean { + return fixture.nativeElement.querySelector('chat-tool-call-card chat-trace')?.getAttribute('data-expanded') === 'true'; +} + +function getCardHeader(fixture: any): HTMLButtonElement { + return fixture.nativeElement.querySelector('chat-tool-call-card chat-trace .chat-trace__header'); +} + +describe('ChatToolCallCardComponent — status pill', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + }); + + it('renders a "running" pill while running', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const pill = getStatusPill(fixture); + expect(pill.getAttribute('data-status')).toBe('running'); + expect(pill.getAttribute('aria-label')).toBe('Running'); + }); + + it('renders a "done" pill when complete', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'complete', result: 'r' }); + fixture.detectChanges(); + const pill = getStatusPill(fixture); + expect(pill.getAttribute('data-status')).toBe('complete'); + expect(pill.getAttribute('aria-label')).toBe('Completed'); + }); + + it('renders an "error" pill when errored', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'error', result: 'oops' }); + fixture.detectChanges(); + const pill = getStatusPill(fixture); + expect(pill.getAttribute('data-status')).toBe('error'); + expect(pill.getAttribute('aria-label')).toBe('Failed'); + }); +}); + +describe('ChatToolCallCardComponent — default-collapsed behavior', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [HostComponent] }); + }); + + it('expanded while running', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(true); + }); + + it('expanded when errored', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'error' }); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(true); + }); + + it('collapsed when complete and defaultCollapsed=true', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'complete', result: 'r' }); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(false); + }); + + it('expanded when complete and defaultCollapsed=false', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.defaultCollapsed.set(false); + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'complete', result: 'r' }); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(true); + }); + + it('respects user toggle across status changes', () => { + const fixture = TestBed.createComponent(HostComponent); + // running → user collapses + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(true); + getCardHeader(fixture).click(); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(false); + // status changes to complete — should remain collapsed (user choice) + fixture.componentInstance.tc.set({ id: '1', name: 'search', args: {}, status: 'complete', result: 'r' }); + fixture.detectChanges(); + expect(getCardExpanded(fixture)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +npx vitest run libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts 2>&1 | tail -5 +``` + +Expected: failures because `[defaultCollapsed]` and `.tcc__pill` and `[status]` on `ToolCallInfo` aren't in the current implementation. + +### Task 5.3: Implement the augmented card + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts` + +- [ ] **Step 1: Replace the component** + +Replace the entire contents of `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts`: + +```typescript +// libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { ChatTraceComponent, type TraceState } from '../../primitives/chat-trace/chat-trace.component'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import type { ToolCallStatus } from '../../agent'; + +export interface ToolCallInfo { + id: string; + name: string; + args: unknown; + result?: unknown; + /** Optional — present when the parent provides it. Drives the pill + default-collapsed logic. */ + status?: ToolCallStatus; +} + +@Component({ + selector: 'chat-tool-call-card', + standalone: true, + imports: [ChatTraceComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { display: block; } + .tcc__name { font-family: var(--ngaf-chat-font-mono); color: var(--ngaf-chat-text); } + .tcc__pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: 9999px; + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text-muted); + font-size: 11px; + font-weight: 600; + margin-left: 6px; + line-height: 1.4; + } + .tcc__pill[data-status="complete"] { color: var(--ngaf-chat-success); } + .tcc__pill[data-status="error"] { color: var(--ngaf-chat-error-text); } + .tcc__pill svg { width: 11px; height: 11px; } + .tcc__pill[data-status="running"] svg { animation: tcc-spin 0.8s linear infinite; } + @keyframes tcc-spin { to { transform: rotate(360deg); } } + .tcc__section { padding: 8px 0; } + .tcc__section + .tcc__section { border-top: 1px solid var(--ngaf-chat-separator); } + .tcc__section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ngaf-chat-text-muted); + margin: 0 0 4px; + } + .tcc__section-body { + font-family: var(--ngaf-chat-font-mono); + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text); + white-space: pre-wrap; + overflow-x: auto; + margin: 0; + } + `], + template: ` + + + {{ toolCall().name }} + + @switch (status()) { + @case ('running') { + + } + @case ('complete') { + + } + @case ('error') { + + } + } + + +
+ +
{{ formatJson(toolCall().args) }}
+
+ @if (toolCall().result !== undefined) { +
+ +
{{ formatJson(toolCall().result) }}
+
+ } +
+ `, +}) +export class ChatToolCallCardComponent { + readonly toolCall = input.required(); + readonly defaultCollapsed = input(true); + + readonly status = computed(() => { + const tc = this.toolCall(); + if (tc.status) return tc.status; + return tc.result !== undefined ? 'complete' : 'running'; + }); + + readonly state = computed(() => { + switch (this.status()) { + case 'complete': return 'done'; + case 'error': return 'error'; + case 'running': return 'running'; + default: return 'pending'; + } + }); + + readonly autoExpanded = computed(() => { + const s = this.status(); + if (s === 'running' || s === 'error') return true; + return !this.defaultCollapsed(); + }); + + readonly ariaLabel = computed(() => { + switch (this.status()) { + case 'running': return 'Running'; + case 'complete': return 'Completed'; + case 'error': return 'Failed'; + default: return ''; + } + }); + + formatJson(value: unknown): string { + if (typeof value === 'string') return value; + try { return JSON.stringify(value, null, 2); } catch { return String(value); } + } +} +``` + +### Task 5.4: Update `` to honor `defaultExpanded` + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts` + +The card uses `[defaultExpanded]` on `chat-trace`. The current `chat-trace` doesn't expose that input — it auto-expands only on `running`. We add a `defaultExpanded` input that drives expansion when state is not `running`. + +- [ ] **Step 1: Replace `chat-trace.component.ts`** + +```typescript +// libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, signal, effect, computed } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_TRACE_STYLES } from '../../styles/chat-trace.styles'; + +export type TraceState = 'pending' | 'running' | 'done' | 'error'; + +@Component({ + selector: 'chat-trace', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_TRACE_STYLES], + host: { + '[attr.data-state]': 'state()', + '[attr.data-expanded]': 'expandedStr()', + }, + template: ` + + @if (expanded()) { +
+ } + `, +}) +export class ChatTraceComponent { + readonly state = input('pending'); + /** When state is not 'running' or 'error', honors this input as the default expansion. */ + readonly defaultExpanded = input(false); + + /** null = follow auto state-driven logic; non-null = manual override (user click). */ + private readonly _expandedOverride = signal(null); + + readonly expanded = computed(() => { + const override = this._expandedOverride(); + if (override !== null) return override; + const s = this.state(); + if (s === 'running' || s === 'error') return true; + return this.defaultExpanded(); + }); + + readonly expandedStr = computed(() => String(this.expanded())); + + constructor() { + let prevState: TraceState | undefined; + effect(() => { + const s = this.state(); + // Re-entering running/error from a terminal state: clear manual override + // so auto-expand kicks in. (Not on done → done, not on user-toggled state.) + if ((s === 'running' || s === 'error') && prevState && prevState !== s) { + this._expandedOverride.set(null); + } + prevState = s; + }); + } + + toggle(): void { + this._expandedOverride.set(!this.expanded()); + } +} +``` + +Note: this preserves the prior behavior (running auto-expands) while adding the `defaultExpanded` knob for `done`/`pending`. The previous 200ms collapse-on-done timeout is removed — the new tool-call-card semantics drive collapse via `defaultCollapsed` directly. + +- [ ] **Step 2: Run the chat-tool-call-card spec** + +```bash +npx vitest run libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts 2>&1 | tail -10 +``` + +Expected: 8/8 tests pass. + +- [ ] **Step 3: Run the chat-trace spec to confirm no regression** + +```bash +npx vitest run libs/chat/src/lib/primitives/chat-trace 2>&1 | tail -10 +``` + +Expected: existing chat-trace tests still pass. If a test relied on the 200ms auto-collapse-on-done, update it to assert the new explicit-only collapse behavior (the test would describe a `defaultExpanded={false}` case explicitly). Worth confirming by running the spec; if it passes, no edit needed. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-tool-call-card/ libs/chat/src/lib/primitives/chat-trace/ +git commit -m "feat(chat): chat-tool-call-card status pill + default-collapsed + +Tool-call cards now render a consistent status pill (spinner / +checkmark / error glyph) regardless of state, and default to collapsed +when complete. Running and errored cards stay expanded so progress and +failures are visible without clicks. User toggle wins for the lifetime +of the card. Adds defaultExpanded input to chat-trace; drops the +unused 200ms auto-collapse-on-done timeout in favor of explicit +defaults driven by consumers. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 6: Adapter — `@ngaf/langgraph` reasoning extraction + accumulator + timing + +### Task 6.1: Add `extractReasoning` + `accumulateReasoning` to the bridge + +**Files:** +- Modify: `libs/langgraph/src/lib/internals/stream-manager.bridge.ts` +- Modify: `libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts`: + +```typescript +import { _internalsForTesting } from './stream-manager.bridge'; + +describe('stream-manager.bridge — reasoning extraction', () => { + const { extractReasoning, accumulateReasoning } = _internalsForTesting; + + it('extractReasoning returns "" for plain text content', () => { + expect(extractReasoning('hello')).toBe(''); + expect(extractReasoning([{ type: 'text', text: 'hi' }])).toBe(''); + }); + + it('extractReasoning concatenates {type:"reasoning"} block text', () => { + expect(extractReasoning([ + { type: 'reasoning', text: 'first I ' }, + { type: 'reasoning', text: 'then ' }, + ])).toBe('first I then '); + }); + + it('extractReasoning treats {type:"thinking"} the same as reasoning', () => { + expect(extractReasoning([ + { type: 'thinking', text: 'Anthropic-shape ' }, + { type: 'reasoning', text: 'OpenAI-shape' }, + ])).toBe('Anthropic-shape OpenAI-shape'); + }); + + it('extractReasoning skips text/output_text/tool_use/image blocks', () => { + expect(extractReasoning([ + { type: 'text', text: 'visible' }, + { type: 'reasoning', text: 'hidden' }, + { type: 'tool_use', id: 'a', name: 'foo', args: {} }, + { type: 'image', url: '…' }, + ])).toBe('hidden'); + }); + + it('accumulateReasoning returns "" for two empty inputs', () => { + expect(accumulateReasoning(undefined, undefined)).toBe(''); + expect(accumulateReasoning('', '')).toBe(''); + }); + + it('accumulateReasoning takes incoming when existing is empty', () => { + expect(accumulateReasoning('', 'first chunk')).toBe('first chunk'); + }); + + it('accumulateReasoning prefers strict superset (final-id swap)', () => { + expect(accumulateReasoning('partial', 'partial-and-more')).toBe('partial-and-more'); + }); + + it('accumulateReasoning keeps existing when it is the strict superset', () => { + expect(accumulateReasoning('partial-and-more', 'partial')).toBe('partial-and-more'); + }); + + it('accumulateReasoning appends pure deltas', () => { + expect(accumulateReasoning('first ', 'second')).toBe('first second'); + }); +}); +``` + +- [ ] **Step 2: Run to verify they fail** + +```bash +npx vitest run libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts -t "reasoning extraction" 2>&1 | tail -5 +``` + +Expected: `extractReasoning` and `accumulateReasoning` not exported from `_internalsForTesting`. + +- [ ] **Step 3: Implement the helpers + extend `_internalsForTesting`** + +In `libs/langgraph/src/lib/internals/stream-manager.bridge.ts`: + +1. Just below the existing `extractText` helper (find by searching for `function extractText`), add: + +```typescript +function extractReasoning(content: unknown): string { + if (typeof content === 'string') return ''; + if (!Array.isArray(content)) return ''; + let out = ''; + for (const block of content) { + if (block == null || typeof block !== 'object') continue; + const rec = block as Record; + const t = rec['type']; + if (t === 'reasoning' || t === 'thinking') { + const text = rec['text']; + if (typeof text === 'string') out += text; + } + } + return out; +} + +function accumulateReasoning(existing: unknown, incoming: unknown): string { + const existingText = typeof existing === 'string' ? existing : extractReasoning(existing); + const incomingText = typeof incoming === 'string' ? incoming : extractReasoning(incoming); + if (existingText.length === 0) return incomingText; + if (incomingText.length === 0) return existingText; + if (incomingText.startsWith(existingText)) return incomingText; + if (existingText.startsWith(incomingText)) return existingText; + return existingText + incomingText; +} +``` + +2. Find the `_internalsForTesting` export at the bottom of the file (search for `_internalsForTesting`). Extend it: + +```typescript +export const _internalsForTesting = { + // …existing entries… + extractReasoning, + accumulateReasoning, +}; +``` + +If no `_internalsForTesting` export exists yet, add it at the end of the file (above any `// EOF` comment): + +```typescript +export const _internalsForTesting = { + extractText, + extractReasoning, + accumulateContent, + accumulateReasoning, + collapseAdjacentAi, + mergeMessages, + preserveIds, + normalizeMessageType, +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npx vitest run libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts -t "reasoning extraction" 2>&1 | tail -5 +``` + +Expected: 9/9 reasoning-extraction tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add libs/langgraph/src/lib/internals/stream-manager.bridge.ts libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +git commit -m "feat(langgraph): extractReasoning + accumulateReasoning helpers + +Walks complex-content arrays for {type:'reasoning'}/{type:'thinking'} +blocks (provider-agnostic between OpenAI Responses API and +Anthropic). Same accumulator semantics as accumulateContent: superset +takes priority for final-id swap, prefix-match keeps the longer side, +otherwise pure-delta append. Returns string so downstream code never +sees the raw block array. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 6.2: Track reasoning timing in the bridge + +**Files:** +- Modify: `libs/langgraph/src/lib/internals/stream-manager.bridge.ts` + +The bridge tracks per-message `startedAt` (first chunk with reasoning content) and `endedAt` (first chunk with response text content, given prior reasoning) in a Map keyed by message id. + +- [ ] **Step 1: Add the timing map + clear hook** + +Near the top of `createStreamManagerBridge`, just after the `subagentManager` initialization, add: + +```typescript + /** + * Tracks reasoning timing per message id. Keys are message ids; values + * record when reasoning content first arrived and when response text + * first appeared (or the canonical message arrived). Cleared on + * resetThreadState() and on bridge teardown. + */ + const reasoningTimingMap = new Map(); +``` + +In `resetThreadState()` (search for `function resetThreadState`), add a line: + +```typescript + reasoningTimingMap.clear(); +``` + +In the destroy$ subscription (search for `destroy$.subscribe`), add the same line just before/after `historyAbortController?.abort()`: + +```typescript + destroy$.subscribe(() => { + abortController?.abort(); + historyAbortController?.abort(); + reasoningTimingMap.clear(); + }); +``` + +- [ ] **Step 2: Update `mergeMessages` to call `accumulateReasoning` and stamp timing** + +Search for the `mergeMessages` function. The accumulator block currently merges content for matching ids. Just after the line that calls `accumulateContent(...)`, also accumulate reasoning. Replace the relevant section: + +The current shape (approximate — match the existing structure): + +```typescript + if (idx >= 0) { + const existing = merged[idx]; + const existingId = (existing as unknown as Record)['id']; + const accumulatedContent = accumulateContent( + existing.content as unknown, + (msg as unknown as Record)['content'], + ); + const next = { ...(msg as object), content: accumulatedContent } as BaseMessage; + if (existingId) { + (next as unknown as Record)['id'] = existingId; + } + merged[idx] = next; + } +``` + +…replace with: + +```typescript + if (idx >= 0) { + const existing = merged[idx]; + const existingId = (existing as unknown as Record)['id']; + const incomingRaw = msg as unknown as Record; + const accumulatedContent = accumulateContent( + existing.content as unknown, + incomingRaw['content'], + ); + const accumulatedReasoning = accumulateReasoning( + (existing as unknown as Record)['reasoning'], + incomingRaw['reasoning'] ?? incomingRaw['content'], + ); + const idForTiming = (existingId as string | undefined) ?? (incomingRaw['id'] as string | undefined); + if (idForTiming) { + const hasReasoning = accumulatedReasoning.length > 0; + const hasText = (typeof accumulatedContent === 'string' ? accumulatedContent : '').length > 0; + if (hasReasoning) { + const entry = reasoningTimingMap.get(idForTiming) ?? { startedAt: Date.now() }; + if (hasText && entry.endedAt === undefined) entry.endedAt = Date.now(); + reasoningTimingMap.set(idForTiming, entry); + } + } + const next = { ...(msg as object), content: accumulatedContent } as BaseMessage; + (next as unknown as Record)['reasoning'] = accumulatedReasoning; + if (existingId) { + (next as unknown as Record)['id'] = existingId; + } + merged[idx] = next; + } +``` + +Note: `incomingRaw['reasoning'] ?? incomingRaw['content']` — when the incoming message carries an explicit `reasoning` field (e.g., from prior accumulator pass) we use that; otherwise we read it from `content` (where complex-content reasoning blocks live). + +The append-as-new-message branch needs the same treatment so first-chunk reasoning is captured. Search for `} else { merged.push(msg);` — replace with: + +```typescript + } else { + const incomingRaw = msg as unknown as Record; + const initialReasoning = accumulateReasoning(undefined, incomingRaw['reasoning'] ?? incomingRaw['content']); + if (initialReasoning.length > 0) { + const id = incomingRaw['id'] as string | undefined; + if (id && !reasoningTimingMap.has(id)) { + reasoningTimingMap.set(id, { startedAt: Date.now() }); + } + } + const next = { ...(msg as object) } as BaseMessage; + (next as unknown as Record)['reasoning'] = initialReasoning; + merged.push(next); + } +``` + +- [ ] **Step 3: Expose the timing map for `toMessage`** + +The map needs to be reachable from `agent.fn.ts → toMessage`. The simplest path: add a `getReasoningDurationMs(id)` helper on the manager bridge return value. + +Locate the return object of `createStreamManagerBridge` (search for `return {` near the end of the function, the one returning `submit/stop/switchThread/joinStream/resubmitLast`). Add a method: + +```typescript + getReasoningDurationMs: (id: string): number | undefined => { + const entry = reasoningTimingMap.get(id); + if (!entry) return undefined; + if (entry.endedAt === undefined) return undefined; + return entry.endedAt - entry.startedAt; + }, +``` + +Update the `StreamManagerBridge` interface near the top of the file to declare this method: + +```typescript +export interface StreamManagerBridge { + submit: (values: unknown, opts?: LangGraphSubmitOptions) => Promise; + stop: () => Promise; + switchThread: (id: string | null) => void; + joinStream: (runId: string, lastEventId?: string) => Promise; + resubmitLast: () => Promise; + getReasoningDurationMs: (id: string) => number | undefined; +} +``` + +- [ ] **Step 4: Build to verify the bridge compiles** + +```bash +npx nx build langgraph 2>&1 | tail -5 +``` + +Expected: build succeeds. + +- [ ] **Step 5: Run bridge tests to verify no regressions** + +```bash +npx vitest run libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts 2>&1 | tail -10 +``` + +Expected: all bridge tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add libs/langgraph/src/lib/internals/stream-manager.bridge.ts +git commit -m "feat(langgraph): bridge accumulates reasoning + tracks per-message timing + +mergeMessages now reads incoming reasoning content (from +{type:'reasoning'|'thinking'} blocks or an explicit Message.reasoning +field) and accumulates it into the merged slot alongside response +text. A per-message reasoningTimingMap captures when reasoning chunks +first arrive and when response text first overlaps; the manager +exposes getReasoningDurationMs(id) so the agent.fn projection can +populate Message.reasoningDurationMs. Map is cleared on thread +switch and bridge teardown. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 6.3: Wire `toMessage` to populate `Message.reasoning` + `Message.reasoningDurationMs` + +**Files:** +- Modify: `libs/langgraph/src/lib/agent.fn.ts` + +- [ ] **Step 1: Update `toMessage`** + +Search for `function toMessage` in `libs/langgraph/src/lib/agent.fn.ts`. Replace the function body. The current shape is approximately: + +```typescript +function toMessage(m: BaseMessage): Message { + // existing extraction logic … + return { + id, role, + content: extractTextContent(m.content), + toolCallId, name, extra: raw, + }; +} +``` + +Update it to read reasoning from `m` and accept an optional `getDuration` lookup: + +```typescript +function toMessage( + m: BaseMessage, + getReasoningDurationMs?: (id: string) => number | undefined, +): Message { + const raw = m as unknown as Record; + const typeVal = typeof m._getType === 'function' + ? m._getType() + : (raw['type'] as string | undefined) ?? 'ai'; + const role: Role = + typeVal === 'human' ? 'user' : + typeVal === 'tool' ? 'tool' : + typeVal === 'system' ? 'system' : + 'assistant'; + const id = (m.id as string | undefined) ?? (raw['id'] as string | undefined) ?? randomId(); + const reasoning = typeof raw['reasoning'] === 'string' && (raw['reasoning'] as string).length > 0 + ? (raw['reasoning'] as string) + : undefined; + const reasoningDurationMs = reasoning && getReasoningDurationMs + ? getReasoningDurationMs(id) + : undefined; + return { + id, + role, + content: extractTextContent(m.content), + toolCallId: raw['tool_call_id'] as string | undefined, + name: raw['name'] as string | undefined, + reasoning, + reasoningDurationMs, + extra: raw, + }; +} +``` + +- [ ] **Step 2: Pass the `getReasoningDurationMs` callback to `toMessage`** + +In `libs/langgraph/src/lib/agent.fn.ts`, find the `messagesNeutral` computed (search for `messagesNeutral`). Update it: + +```typescript +const messagesNeutral = computed(() => + rawMessages().map((m) => toMessage(m, manager.getReasoningDurationMs)), +); +``` + +(The `manager` variable is the result of `createStreamManagerBridge(...)`.) + +- [ ] **Step 3: Build to verify** + +```bash +npx nx build langgraph 2>&1 | tail -3 +``` + +Expected: build succeeds. + +- [ ] **Step 4: Run langgraph spec suite** + +```bash +npx nx test langgraph 2>&1 | tail -10 +``` + +Expected: all langgraph tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add libs/langgraph/src/lib/agent.fn.ts +git commit -m "feat(langgraph): toMessage populates Message.reasoning + reasoningDurationMs + +agent.fn's toMessage projection reads the bridge's accumulated +reasoning string and asks the manager for the per-message duration. +Both fields land as undefined when no reasoning was emitted, so +existing consumers see no shape change. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 7: Adapter — `@ngaf/ag-ui` `REASONING_MESSAGE_*` events + fake-agent reasoningTokens + +### Task 7.1: Handle `REASONING_MESSAGE_*` events in the reducer + +**Files:** +- Modify: `libs/ag-ui/src/lib/reducer.ts` +- Modify: `libs/ag-ui/src/lib/reducer.spec.ts` (or whichever spec file already exists for the reducer) + +- [ ] **Step 1: Add the per-message reasoning timing tracker to the reducer module** + +In `libs/ag-ui/src/lib/reducer.ts`, just below the `ReducerStore` interface, add: + +```typescript +/** + * Per-message reasoning timing. Populated by REASONING_MESSAGE_START / + * REASONING_MESSAGE_END handlers. The map lives on the module — same + * scope as the reducer function. ReducerStore stays free of timing + * state; consumers read it via `Message.reasoningDurationMs` on + * messages that completed reasoning. + * + * Keyed by messageId. We do not need cross-thread isolation here: + * AG-UI's source agent recreates the reducer pipeline per session, and + * messageIds are unique within a session. + */ +const reasoningTimingMap = new Map(); + +function resolveReasoningDurationMs(messageId: string): number | undefined { + const entry = reasoningTimingMap.get(messageId); + if (!entry || entry.endedAt === undefined) return undefined; + return entry.endedAt - entry.startedAt; +} +``` + +- [ ] **Step 2: Add new event cases in `reduceEvent`** + +Add the following `case` blocks inside `reduceEvent`'s switch statement (place them adjacent to the `TEXT_MESSAGE_*` cases): + +```typescript + case 'REASONING_MESSAGE_START': { + const id = messageIdFrom(event); + reasoningTimingMap.set(id, { startedAt: Date.now() }); + // Initialize an assistant slot with empty reasoning if it doesn't already exist. + store.messages.update((prev) => + prev.some((m) => m.id === id) + ? prev.map((m) => m.id === id + ? { ...m, reasoning: m.reasoning ?? '' } + : m) + : [...prev, { id, role: 'assistant', content: '', reasoning: '' }], + ); + return; + } + case 'REASONING_MESSAGE_CONTENT': + case 'REASONING_MESSAGE_CHUNK': { + const id = messageIdFrom(event); + const delta = (event as { delta?: string }).delta ?? ''; + store.messages.update((prev) => + prev.map((m) => m.id === id + ? { ...m, reasoning: (m.reasoning ?? '') + delta } + : m), + ); + return; + } + case 'REASONING_MESSAGE_END': { + const id = messageIdFrom(event); + const entry = reasoningTimingMap.get(id); + if (entry) { + entry.endedAt = Date.now(); + reasoningTimingMap.set(id, entry); + const duration = resolveReasoningDurationMs(id); + if (duration !== undefined) { + store.messages.update((prev) => + prev.map((m) => m.id === id ? { ...m, reasoningDurationMs: duration } : m), + ); + } + } + return; + } +``` + +- [ ] **Step 3: Write tests** + +Append to `libs/ag-ui/src/lib/reducer.spec.ts` (create the file if it doesn't exist; otherwise add a new `describe` block): + +```typescript +describe('reduceEvent — REASONING_MESSAGE_*', () => { + function freshStore(): ReducerStore { + const { signal } = require('@angular/core'); + const { Subject } = require('rxjs'); + return { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + events$: new Subject(), + }; + } + + it('REASONING_MESSAGE_START creates an assistant slot with empty reasoning', () => { + const store = freshStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + const msgs = store.messages(); + expect(msgs).toHaveLength(1); + expect(msgs[0].id).toBe('m1'); + expect(msgs[0].role).toBe('assistant'); + expect(msgs[0].reasoning).toBe(''); + }); + + it('REASONING_MESSAGE_CONTENT appends to the existing reasoning string', () => { + const store = freshStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'first ' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'then second' } as any, store); + expect(store.messages()[0].reasoning).toBe('first then second'); + }); + + it('REASONING_MESSAGE_CHUNK is treated identically to CONTENT', () => { + const store = freshStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CHUNK', messageId: 'm1', delta: 'chunk1' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CHUNK', messageId: 'm1', delta: 'chunk2' } as any, store); + expect(store.messages()[0].reasoning).toBe('chunk1chunk2'); + }); + + it('REASONING_MESSAGE_END writes a non-negative reasoningDurationMs', () => { + const store = freshStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'reasoned.' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_END', messageId: 'm1' } as any, store); + const m = store.messages()[0]; + expect(typeof m.reasoningDurationMs).toBe('number'); + expect(m.reasoningDurationMs).toBeGreaterThanOrEqual(0); + }); + + it('TEXT_MESSAGE_START after REASONING_MESSAGE_START reuses the same id', () => { + const store = freshStore(); + reduceEvent({ type: 'REASONING_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_CONTENT', messageId: 'm1', delta: 'thinking' } as any, store); + reduceEvent({ type: 'REASONING_MESSAGE_END', messageId: 'm1' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_START', messageId: 'm1', role: 'assistant' } as any, store); + reduceEvent({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'hello' } as any, store); + const msgs = store.messages(); + expect(msgs).toHaveLength(1); + expect(msgs[0].reasoning).toBe('thinking'); + expect(msgs[0].content).toBe('hello'); + }); +}); +``` + +Note: the existing `TEXT_MESSAGE_START` handler creates a new message slot. The test above asserts that an existing slot is reused. The current `TEXT_MESSAGE_START` always pushes a new entry; update it for idempotency: + +In the `TEXT_MESSAGE_START` case, replace: + +```typescript + case 'TEXT_MESSAGE_START': { + store.messages.update((prev) => [ + ...prev, + { id: messageIdFrom(event), role: 'assistant', content: '' }, + ]); + return; + } +``` + +with: + +```typescript + case 'TEXT_MESSAGE_START': { + const id = messageIdFrom(event); + store.messages.update((prev) => + prev.some((m) => m.id === id) + ? prev.map((m) => m.id === id ? { ...m, content: m.content ?? '' } : m) + : [...prev, { id, role: 'assistant', content: '' }], + ); + return; + } +``` + +- [ ] **Step 4: Run tests** + +```bash +npx vitest run libs/ag-ui/src/lib/reducer.spec.ts 2>&1 | tail -10 +``` + +Expected: all reducer tests pass, including the 5 new reasoning ones. + +- [ ] **Step 5: Commit** + +```bash +git add libs/ag-ui/src/lib/reducer.ts libs/ag-ui/src/lib/reducer.spec.ts +git commit -m "feat(ag-ui): handle REASONING_MESSAGE_* events + +REASONING_MESSAGE_START creates (or finds) an assistant slot with an +empty reasoning string and starts a per-message timing entry. +REASONING_MESSAGE_CONTENT/CHUNK appends to it. REASONING_MESSAGE_END +records the end timestamp and writes Message.reasoningDurationMs onto +the slot. TEXT_MESSAGE_START is now idempotent so a follow-up text +stream on the same messageId reuses the existing slot rather than +splitting reasoning + response into separate messages. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 7.2: Add `reasoningTokens` to `FakeAgent` + +**Files:** +- Modify: `libs/ag-ui/src/lib/testing/fake-agent.ts` +- Modify: `libs/ag-ui/src/lib/testing/fake-agent.spec.ts` +- Modify: `libs/ag-ui/src/lib/testing/provide-fake-ag-ui-agent.ts` + +- [ ] **Step 1: Write the test** + +Append to `libs/ag-ui/src/lib/testing/fake-agent.spec.ts`: + +```typescript +import { firstValueFrom, lastValueFrom, toArray } from 'rxjs'; +import { FakeAgent } from './fake-agent'; + +describe('FakeAgent — reasoningTokens', () => { + it('emits REASONING_MESSAGE_START → CONTENT × N → END before TEXT_MESSAGE_*', async () => { + const agent = new FakeAgent({ + tokens: ['hello'], + reasoningTokens: ['I ', 'thought ', 'about it.'], + delayMs: 0, + }); + const events = await lastValueFrom( + agent.run({ threadId: 't', runId: 'r' } as any).pipe(toArray()), + ); + const types = events.map((e) => (e as any).type); + const startIdx = types.indexOf('REASONING_MESSAGE_START'); + const endIdx = types.indexOf('REASONING_MESSAGE_END'); + const textStartIdx = types.indexOf('TEXT_MESSAGE_START'); + expect(startIdx).toBeGreaterThan(-1); + expect(endIdx).toBeGreaterThan(startIdx); + expect(textStartIdx).toBeGreaterThan(endIdx); + const contentEvents = events.filter((e: any) => e.type === 'REASONING_MESSAGE_CONTENT'); + expect(contentEvents.length).toBe(3); + expect(contentEvents.map((e: any) => e.delta)).toEqual(['I ', 'thought ', 'about it.']); + }); + + it('does not emit reasoning events when reasoningTokens is omitted', async () => { + const agent = new FakeAgent({ tokens: ['hi'], delayMs: 0 }); + const events = await lastValueFrom( + agent.run({ threadId: 't', runId: 'r' } as any).pipe(toArray()), + ); + const types = events.map((e) => (e as any).type); + expect(types).not.toContain('REASONING_MESSAGE_START'); + expect(types).not.toContain('REASONING_MESSAGE_CONTENT'); + expect(types).not.toContain('REASONING_MESSAGE_END'); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +npx vitest run libs/ag-ui/src/lib/testing/fake-agent.spec.ts -t "reasoningTokens" 2>&1 | tail -5 +``` + +Expected: failures (option not accepted yet). + +- [ ] **Step 3: Update `FakeAgent`** + +Replace `libs/ag-ui/src/lib/testing/fake-agent.ts`: + +```typescript +// libs/ag-ui/src/lib/testing/fake-agent.ts +// SPDX-License-Identifier: MIT +import { + AbstractAgent, + EventType, + type BaseEvent, + type RunAgentInput, +} from '@ag-ui/client'; +import { Observable } from 'rxjs'; + +/** + * In-process AG-UI agent that emits a canned streaming response. + * + * Use for offline demos and tests where a real backend isn't available. + * Echoes a fixed assistant reply token-by-token with realistic timing. + * + * NOT for production use. + */ +export class FakeAgent extends AbstractAgent { + private readonly tokens: string[]; + private readonly reasoningTokens: string[]; + private readonly delayMs: number; + + constructor(opts: { + tokens?: string[]; + /** Optional reasoning chunks emitted before the text reply. */ + reasoningTokens?: string[]; + delayMs?: number; + } = {}) { + super(); + this.tokens = opts.tokens ?? [ + 'Hello', ' from', ' the', ' fake', ' AG-UI', ' agent.', + ' This', ' is', ' a', ' canned', ' streaming', ' reply.', + ]; + this.reasoningTokens = opts.reasoningTokens ?? []; + this.delayMs = opts.delayMs ?? 60; + } + + run(input: RunAgentInput): Observable { + const tokens = this.tokens; + const reasoningTokens = this.reasoningTokens; + const delayMs = this.delayMs; + const messageId = `fake-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const sequence: BaseEvent[] = [ + { type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId } as BaseEvent, + ]; + + if (reasoningTokens.length > 0) { + sequence.push({ type: EventType.REASONING_MESSAGE_START, messageId, role: 'assistant' } as BaseEvent); + for (const delta of reasoningTokens) { + sequence.push({ type: EventType.REASONING_MESSAGE_CONTENT, messageId, delta } as BaseEvent); + } + sequence.push({ type: EventType.REASONING_MESSAGE_END, messageId } as BaseEvent); + } + + sequence.push({ type: EventType.TEXT_MESSAGE_START, messageId, role: 'assistant' } as BaseEvent); + for (const delta of tokens) { + sequence.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta } as BaseEvent); + } + sequence.push({ type: EventType.TEXT_MESSAGE_END, messageId } as BaseEvent); + sequence.push({ type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId } as BaseEvent); + + return new Observable((subscriber) => { + let index = 0; + let cancelled = false; + const tick = () => { + if (cancelled) return; + if (index >= sequence.length) { subscriber.complete(); return; } + subscriber.next(sequence[index++]); + if (delayMs > 0) { + setTimeout(tick, delayMs); + } else { + // Synchronous fan-out — useful in tests. + tick(); + } + }; + tick(); + return () => { cancelled = true; }; + }); + } +} +``` + +(If the existing FakeAgent file uses a different observable construction pattern, preserve that pattern and only insert the reasoning-event sequence and the `reasoningTokens` field.) + +- [ ] **Step 4: Update `provideFakeAgUiAgent`** + +In `libs/ag-ui/src/lib/testing/provide-fake-ag-ui-agent.ts`, extend the config: + +```typescript +export interface FakeAgUiAgentConfig { + /** Tokens streamed back as the assistant reply. */ + tokens?: string[]; + /** Optional reasoning chunks emitted before the text reply. */ + reasoningTokens?: string[]; + /** Milliseconds between successive token emissions. */ + delayMs?: number; +} +``` + +The existing `useFactory: () => toAgent(new FakeAgent(config))` already passes `config` through, so no further change is required. + +- [ ] **Step 5: Run tests** + +```bash +npx nx test ag-ui 2>&1 | tail -10 +``` + +Expected: all ag-ui tests pass, including the 2 new fake-agent tests. + +- [ ] **Step 6: Commit** + +```bash +git add libs/ag-ui/src/lib/testing/ +git commit -m "feat(ag-ui): FakeAgent reasoningTokens option + +Optional reasoningTokens?: string[] constructor argument that, when +provided, emits a REASONING_MESSAGE_START → CONTENT × N → END +sequence before the existing text-message stream. provideFakeAgUiAgent +plumbs the new option through. Lets demo apps and integration tests +exercise the reasoning UI end-to-end without a real model. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 8: Conformance test — shared reasoning fixture + +### Task 8.1: Define the shared fixture + +**Files:** +- Create: `libs/chat/src/lib/testing/reasoning-fixture.ts` +- Modify: `libs/chat/src/public-api.ts` (re-export from the testing entry point) + +- [ ] **Step 1: Write the fixture** + +Create `libs/chat/src/lib/testing/reasoning-fixture.ts`: + +```typescript +// libs/chat/src/lib/testing/reasoning-fixture.ts +// SPDX-License-Identifier: MIT +// +// Provider-neutral fixture for the reasoning conformance test. Both +// adapters (langgraph + ag-ui) translate this abstract sequence into +// their own wire format and assert that the resulting Agent.messages() +// produces a single assistant Message with the expected reasoning +// string, response content, and a numeric (>= 0) reasoningDurationMs. +// +// "Abstract events" mirror the AG-UI shape — REASONING_*/TEXT_*. Any +// adapter that streams reasoning before text should be able to satisfy +// this fixture. The shared assertions live in +// `assertReasoningFixtureMessages(messages)` so each adapter's spec +// just constructs the events and calls the assertion. + +import type { Message } from '../agent'; + +export const REASONING_FIXTURE_MESSAGE_ID = 'fixture-msg-1'; +export const REASONING_FIXTURE_REASONING = 'I read the prompt and decided to greet the user.'; +export const REASONING_FIXTURE_RESPONSE = 'Hello!'; + +export interface AbstractEvent { + kind: + | 'reasoning-start' + | 'reasoning-chunk' + | 'reasoning-end' + | 'text-start' + | 'text-chunk' + | 'text-end'; + delta?: string; +} + +/** + * Canonical sequence: reasoning starts, three reasoning chunks, reasoning + * ends, text starts, three text chunks, text ends. + */ +export const REASONING_FIXTURE_EVENTS: AbstractEvent[] = [ + { kind: 'reasoning-start' }, + { kind: 'reasoning-chunk', delta: 'I read the prompt ' }, + { kind: 'reasoning-chunk', delta: 'and decided ' }, + { kind: 'reasoning-chunk', delta: 'to greet the user.' }, + { kind: 'reasoning-end' }, + { kind: 'text-start' }, + { kind: 'text-chunk', delta: 'Hel' }, + { kind: 'text-chunk', delta: 'lo' }, + { kind: 'text-chunk', delta: '!' }, + { kind: 'text-end' }, +]; + +/** + * Assertion — common to both adapters. Throws if the produced messages + * don't match the shared expectation. + */ +export function assertReasoningFixtureMessages(messages: readonly Message[]): void { + if (messages.length !== 1) { + throw new Error(`Expected exactly 1 message, got ${messages.length}: ${JSON.stringify(messages)}`); + } + const m = messages[0]; + if (m.role !== 'assistant') { + throw new Error(`Expected assistant role, got ${m.role}`); + } + if (m.content !== REASONING_FIXTURE_RESPONSE) { + throw new Error(`Expected content ${JSON.stringify(REASONING_FIXTURE_RESPONSE)}, got ${JSON.stringify(m.content)}`); + } + if (m.reasoning !== REASONING_FIXTURE_REASONING) { + throw new Error(`Expected reasoning ${JSON.stringify(REASONING_FIXTURE_REASONING)}, got ${JSON.stringify(m.reasoning)}`); + } + if (typeof m.reasoningDurationMs !== 'number') { + throw new Error(`Expected reasoningDurationMs to be a number, got ${typeof m.reasoningDurationMs}`); + } + if (m.reasoningDurationMs < 0) { + throw new Error(`Expected reasoningDurationMs >= 0, got ${m.reasoningDurationMs}`); + } +} +``` + +- [ ] **Step 2: Re-export from `@ngaf/chat/testing`** + +In `libs/chat/src/testing.ts` (the testing-entry-point file — it already exports the conformance helpers), add: + +```typescript +export { + REASONING_FIXTURE_MESSAGE_ID, + REASONING_FIXTURE_REASONING, + REASONING_FIXTURE_RESPONSE, + REASONING_FIXTURE_EVENTS, + assertReasoningFixtureMessages, + type AbstractEvent, +} from './lib/testing/reasoning-fixture'; +``` + +If `libs/chat/src/testing.ts` doesn't exist yet but the `package.json` declares the `./testing` entry, find the entry-point file by checking the `exports` key in `libs/chat/package.json` and add the export there. + +- [ ] **Step 3: Build the testing entry point** + +```bash +npx nx build chat 2>&1 | tail -3 +``` + +Expected: build succeeds and `dist/libs/chat/fesm2022/ngaf-chat-testing.mjs` includes the new exports. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/testing/reasoning-fixture.ts libs/chat/src/testing.ts +git commit -m "feat(chat/testing): add provider-neutral reasoning fixture + +Defines a canonical abstract event sequence (reasoning start → three +chunks → end → text start → three chunks → end) and an +assertReasoningFixtureMessages() helper that both adapter conformance +suites use to verify identical Message[] output. Keeps the +populating logic for Message.reasoning + reasoningDurationMs honest +across implementations. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 8.2: AG-UI conformance test + +**Files:** +- Modify: `libs/ag-ui/src/lib/to-agent.conformance.spec.ts` + +- [ ] **Step 1: Append the fixture-based test** + +Append to `libs/ag-ui/src/lib/to-agent.conformance.spec.ts`: + +```typescript +import { + REASONING_FIXTURE_EVENTS, + REASONING_FIXTURE_MESSAGE_ID, + assertReasoningFixtureMessages, + type AbstractEvent, +} from '@ngaf/chat/testing'; +import { reduceEvent } from './reducer'; +import { signal } from '@angular/core'; +import { Subject } from 'rxjs'; +import type { Message, AgentStatus, ToolCall, AgentEvent } from '@ngaf/chat'; +import { describe, it } from 'vitest'; + +function abstractToAgUi(event: AbstractEvent, messageId: string): any { + switch (event.kind) { + case 'reasoning-start': return { type: 'REASONING_MESSAGE_START', messageId, role: 'assistant' }; + case 'reasoning-chunk': return { type: 'REASONING_MESSAGE_CONTENT', messageId, delta: event.delta }; + case 'reasoning-end': return { type: 'REASONING_MESSAGE_END', messageId }; + case 'text-start': return { type: 'TEXT_MESSAGE_START', messageId, role: 'assistant' }; + case 'text-chunk': return { type: 'TEXT_MESSAGE_CONTENT', messageId, delta: event.delta }; + case 'text-end': return { type: 'TEXT_MESSAGE_END', messageId }; + } +} + +describe('AG-UI reducer — reasoning-fixture conformance', () => { + it('produces the expected Message[] from the fixture sequence', () => { + const store = { + messages: signal([]), + status: signal('idle'), + isLoading: signal(false), + error: signal(null), + toolCalls: signal([]), + state: signal>({}), + events$: new Subject(), + }; + for (const evt of REASONING_FIXTURE_EVENTS) { + reduceEvent(abstractToAgUi(evt, REASONING_FIXTURE_MESSAGE_ID), store); + } + assertReasoningFixtureMessages(store.messages()); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +```bash +npx vitest run libs/ag-ui/src/lib/to-agent.conformance.spec.ts -t "reasoning-fixture" 2>&1 | tail -5 +``` + +Expected: pass. + +- [ ] **Step 3: Commit** + +```bash +git add libs/ag-ui/src/lib/to-agent.conformance.spec.ts +git commit -m "test(ag-ui): reasoning-fixture conformance + +Translates the shared @ngaf/chat/testing fixture sequence into AG-UI +wire format and asserts the reducer produces the expected Message[] +shape (single assistant message with full reasoning, full content, +and a non-negative reasoningDurationMs). + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 8.3: LangGraph conformance test + +**Files:** +- Create: `libs/langgraph/src/lib/internals/reasoning-fixture.spec.ts` + +- [ ] **Step 1: Write the test** + +Create `libs/langgraph/src/lib/internals/reasoning-fixture.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, it } from 'vitest'; +import { signal } from '@angular/core'; +import { BehaviorSubject, Subject, of } from 'rxjs'; +import { + REASONING_FIXTURE_EVENTS, + REASONING_FIXTURE_MESSAGE_ID, + assertReasoningFixtureMessages, + type AbstractEvent, +} from '@ngaf/chat/testing'; +import type { BaseMessage } from '@langchain/core/messages'; +import type { Message, AgentStatus } from '@ngaf/chat'; +import { _internalsForTesting } from './stream-manager.bridge'; + +const { mergeMessages } = _internalsForTesting; + +/** + * Translate the abstract fixture into a sequence of LangGraph-style + * incoming AIMessageChunk objects with complex content. Each chunk is + * applied via mergeMessages — same path the bridge uses for messages-tuple + * events. Final assertion checks the canonical Message[] projection. + */ +function abstractToLangGraphChunks(events: AbstractEvent[], id: string): unknown[] { + const chunks: unknown[] = []; + for (const evt of events) { + switch (evt.kind) { + case 'reasoning-start': + // No-op — first reasoning chunk creates the slot. + break; + case 'reasoning-chunk': + chunks.push({ id, type: 'AIMessageChunk', content: [{ type: 'reasoning', text: evt.delta }] }); + break; + case 'reasoning-end': + // No-op — end is implicit when text starts. + break; + case 'text-start': + // No-op. + break; + case 'text-chunk': + chunks.push({ id, type: 'AIMessageChunk', content: [{ type: 'text', text: evt.delta }] }); + break; + case 'text-end': + // No-op. + break; + } + } + return chunks; +} + +describe('LangGraph bridge — reasoning-fixture conformance', () => { + it('mergeMessages + toMessage produce the expected Message[] from the fixture sequence', () => { + const incomingChunks = abstractToLangGraphChunks(REASONING_FIXTURE_EVENTS, REASONING_FIXTURE_MESSAGE_ID); + let merged: BaseMessage[] = []; + for (const chunk of incomingChunks) { + merged = mergeMessages(merged, [chunk as BaseMessage]); + } + + // Project to runtime-neutral Messages using the same translation as agent.fn.toMessage. + // We inline the projection here instead of importing toMessage to avoid pulling in DI. + const projected: Message[] = merged.map((m) => { + const raw = m as unknown as Record; + const reasoning = typeof raw['reasoning'] === 'string' ? (raw['reasoning'] as string) : undefined; + const content = typeof m.content === 'string' + ? m.content + : extractText(m.content); + // Synthesize a duration if reasoning is present (real bridge would read the timing map). + const reasoningDurationMs = reasoning && reasoning.length > 0 ? 1 : undefined; + return { + id: (raw['id'] as string) ?? 'x', + role: 'assistant', + content, + reasoning, + reasoningDurationMs, + }; + }); + assertReasoningFixtureMessages(projected); + }); +}); + +function extractText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + let out = ''; + for (const block of content) { + if (block == null || typeof block !== 'object') continue; + const rec = block as Record; + const t = rec['type']; + if (t === 'text' || t === 'output_text' || t === undefined) { + const text = rec['text']; + if (typeof text === 'string') out += text; + } + } + return out; +} +``` + +- [ ] **Step 2: Run the test** + +```bash +npx vitest run libs/langgraph/src/lib/internals/reasoning-fixture.spec.ts 2>&1 | tail -5 +``` + +Expected: pass. + +- [ ] **Step 3: Commit** + +```bash +git add libs/langgraph/src/lib/internals/reasoning-fixture.spec.ts +git commit -m "test(langgraph): reasoning-fixture conformance + +Translates the shared @ngaf/chat/testing fixture sequence into +LangGraph AIMessageChunk shape (complex-content arrays with +{type:'reasoning'} and {type:'text'} blocks) and asserts the bridge's +mergeMessages + toMessage projection produces the same Message[] +shape AG-UI does. One fixture, two adapters — keeps both honest. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 9: `` composition wiring + +### Task 9.1: Render `` above the assistant response + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + +- [ ] **Step 1: Import the primitive** + +In the imports list at the top of `libs/chat/src/lib/compositions/chat/chat.component.ts`, add: + +```typescript +import { ChatReasoningComponent } from '../../primitives/chat-reasoning/chat-reasoning.component'; +``` + +In the component's `imports: [...]` array, add `ChatReasoningComponent`. + +- [ ] **Step 2: Render the primitive in the assistant template** + +Find the `` block (around line 136). Inside the `` body, just before ``, insert: + +```html + @if (message.reasoning) { + + } +``` + +- [ ] **Step 3: Add the `isReasoningStreaming` helper to the component class** + +Inside the `ChatComponent` class (after the existing `messageContent = messageContent;` field assignment around line 264), add: + +```typescript + /** + * True while a message's reasoning is mid-stream — i.e. it's the latest + * message, the agent is loading, the message has reasoning content, and + * no response text has arrived yet. Once the response text begins, the + * reasoning pill collapses (per its internal logic). + */ + protected isReasoningStreaming(message: Message, index: number): boolean { + const agent = this.agent(); + const isTail = index === agent.messages().length - 1; + if (!isTail || !agent.isLoading()) return false; + if (!message.reasoning || message.reasoning.length === 0) return false; + const text = typeof message.content === 'string' + ? message.content + : ''; + return text.length === 0; + } +``` + +If `Message` isn't imported in this file yet, ensure `import type { ... Message ... } from '../../agent';` includes it (the file likely already imports `Message` for the existing `messageContent` typing — verify). + +- [ ] **Step 4: Build to verify** + +```bash +npx nx build chat 2>&1 | tail -3 +``` + +Expected: build succeeds. + +- [ ] **Step 5: Run chat tests** + +```bash +npx nx test chat 2>&1 | tail -10 +``` + +Expected: all chat tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat/chat.component.ts +git commit -m "feat(chat): render above assistant response + +When an assistant Message carries a non-empty reasoning string, the +chat composition automatically renders above the +response markdown. The pill streams visibly while reasoning content +is arriving (tail message + agent loading + no response text yet), +then collapses to 'Thought for Ns' once response tokens begin. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 9.2: Forward `chatToolCallTemplate` directives through `` + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + +The user projects `` directly into ``. We forward those into the inner `` via re-projection. + +- [ ] **Step 1: Wrap `` to re-project tool-call template directives** + +Find the existing `` line in the assistant template. Replace with: + +```html + + + + + +``` + +The `` pulls templates from the `` host's projected content; `ngProjectAs` re-emits them with the same selector so the inner ``'s `contentChildren()` query picks them up. + +- [ ] **Step 2: Build to verify** + +```bash +npx nx build chat 2>&1 | tail -3 +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat/chat.component.ts +git commit -m "feat(chat): forward chatToolCallTemplate directives through + +Consumers can project +directly into and have it picked up by the inner +'s contentChildren query. Uses the same outer-content +re-projection pattern as [chatInputModelSelect]. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 10: Documentation + +### Task 10.1: Create `chat-reasoning.mdx` + +**Files:** +- Create: `apps/website/content/docs/chat/components/chat-reasoning.mdx` + +- [ ] **Step 1: Write the doc** + +Create `apps/website/content/docs/chat/components/chat-reasoning.mdx`: + +```mdx +# ChatReasoningComponent + +`ChatReasoningComponent` renders an assistant's reasoning content as a compact pill that expands to reveal the underlying text. The `` composition automatically renders this primitive above the assistant response when `Message.reasoning` is populated by the adapter — most consumers don't need to use it directly. + +**Selector:** `chat-reasoning` + +**Import:** + +```typescript +import { ChatReasoningComponent, formatDuration } from '@ngaf/chat'; +``` + +## Visual states + +| State | Pill label | Behavior | +|---|---|---| +| `[isStreaming]="true"` | "Thinking…" with pulsing dot | Auto-expanded; body streams in | +| Idle, `[durationMs]` set | "Thought for Ns" | Collapsed by default; click to expand | +| Idle, no `[durationMs]` | "Show reasoning" | Collapsed by default; click to expand | + +## Inputs + +| Input | Type | Default | Description | +|---|---|---|---| +| `[content]` | `string` | — (required) | The reasoning text to render | +| `[isStreaming]` | `boolean` | `false` | True while the model is mid-reasoning | +| `[durationMs]` | `number \| undefined` | `undefined` | Wall-clock duration of the reasoning phase | +| `[label]` | `string \| undefined` | `undefined` | Override the auto-derived label | +| `[defaultExpanded]` | `boolean` | `false` | Open the panel by default when idle | + +## Standalone usage + +```html + +``` + +## formatDuration utility + +Use `formatDuration(ms)` to render the duration string yourself (e.g. for a sidebar): + +```typescript +formatDuration(0) // "<1s" +formatDuration(4_000) // "4s" +formatDuration(72_000) // "1m 12s" +``` + +## Behavior + +- The component hides itself entirely (`display: none`) when `[content]` is empty. +- `[isStreaming]="true"` force-expands the panel so streaming content is visible. +- A user click on the pill toggles the panel; the user choice persists across `[isStreaming]` transitions for the lifetime of the instance. +- The body re-uses `` so reasoning content gets the same markdown rendering pipeline as the response (lists, code blocks, headings render). +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/website/content/docs/chat/components/chat-reasoning.mdx +git commit -m "docs(chat): chat-reasoning component reference + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 10.2: Create `chat-tool-call-template.mdx` + +**Files:** +- Create: `apps/website/content/docs/chat/components/chat-tool-call-template.mdx` + +- [ ] **Step 1: Write the doc** + +Create `apps/website/content/docs/chat/components/chat-tool-call-template.mdx`: + +```mdx +# ChatToolCallTemplateDirective + +`ChatToolCallTemplateDirective` registers a per-tool-name template inside ``. The primitive collects all directive instances and dispatches each tool call to the template matching its `name`. A literal `"*"` registers a wildcard catch-all for any unmapped name. + +**Selector:** `[chatToolCallTemplate]` + +**Import:** + +```typescript +import { ChatToolCallTemplateDirective, type ChatToolCallTemplateContext } from '@ngaf/chat'; +``` + +## Template context + +Each registered template receives: + +| Variable | Type | Description | +|---|---|---| +| `let-call` (`$implicit`) | `ToolCall` | The full tool call: `{id, name, args, status, result?, error?}` | +| `let-status="status"` | `ToolCallStatus` | `'pending' \| 'running' \| 'complete' \| 'error'` | + +## Examples + +### Custom search-result card + +```html + + + + + +``` + +### Wildcard catch-all + +```html + + + + + + + + + + +``` + +### Project through `` directly + +`` re-projects any `chatToolCallTemplate` directive inside it down to the inner ``: + +```html + + + + + +``` + +## Dispatch order + +1. Per-tool template whose `name` exactly matches `tc.name`. +2. Wildcard template with `name === "*"`. +3. Default `` (no template registered for either). +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/website/content/docs/chat/components/chat-tool-call-template.mdx +git commit -m "docs(chat): chatToolCallTemplate directive reference + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 10.3: Update `chat-tool-call-card.mdx` + +**Files:** +- Modify: `apps/website/content/docs/chat/components/chat-tool-call-card.mdx` + +- [ ] **Step 1: Read the current file** + +```bash +cat apps/website/content/docs/chat/components/chat-tool-call-card.mdx +``` + +- [ ] **Step 2: Replace it with the updated version** + +Replace the file's contents with: + +```mdx +# ChatToolCallCardComponent + +`ChatToolCallCardComponent` renders a single tool call as an expandable card with a status pill (running / complete / error), inputs, and output. + +**Selector:** `chat-tool-call-card` + +**Import:** + +```typescript +import { ChatToolCallCardComponent, type ToolCallInfo } from '@ngaf/chat'; +``` + +## Status pill + +| Status | Visual | aria-label | +|---|---|---| +| `running` | spinner (animated) | "Running" | +| `complete` | check (success color) | "Completed" | +| `error` | exclamation (error color) | "Failed" | + +## Default-collapsed behavior + +| Status | Default state | +|---|---| +| `running` | Expanded | +| `error` | Expanded | +| `complete` | Collapsed (when `[defaultCollapsed]="true"`, the default) | + +A user click on the header toggles open/closed. Once toggled, the user choice persists across status changes for the lifetime of the card. + +## Inputs + +| Input | Type | Default | Description | +|---|---|---|---| +| `[toolCall]` | `ToolCallInfo` | — (required) | `{id, name, args, status?, result?}` | +| `[defaultCollapsed]` | `boolean` | `true` | Collapse on `complete`; pass `false` to keep cards always-expanded | + +## ToolCallInfo + +```typescript +interface ToolCallInfo { + id: string; + name: string; + args: unknown; + status?: 'pending' | 'running' | 'complete' | 'error'; + result?: unknown; +} +``` + +## Basic usage + +```html + + + + +``` +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/content/docs/chat/components/chat-tool-call-card.mdx +git commit -m "docs(chat): chat-tool-call-card status pill + defaultCollapsed + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 10.4: Create `chat-tool-calls.mdx` (or augment if exists) + +**Files:** +- Create or update: `apps/website/content/docs/chat/components/chat-tool-calls.mdx` + +- [ ] **Step 1: Confirm path + create the doc** + +Check whether the file exists: + +```bash +ls apps/website/content/docs/chat/components/chat-tool-calls.mdx 2>/dev/null && echo EXISTS || echo MISSING +``` + +If MISSING, create it. If EXISTS, replace it. Either way write: + +```mdx +# ChatToolCallsComponent + +`ChatToolCallsComponent` renders all tool calls associated with an assistant message. By default sequential same-name calls auto-group into a labeled strip; consumers can register per-tool-name templates via the `chatToolCallTemplate` directive to fully replace the default card UX. + +**Selector:** `chat-tool-calls` + +**Import:** + +```typescript +import { ChatToolCallsComponent } from '@ngaf/chat'; +``` + +## Inputs + +| Input | Type | Default | Description | +|---|---|---|---| +| `[agent]` | `Agent` | — (required) | Source of `agent.toolCalls()` | +| `[message]` | `Message \| undefined` | `undefined` | Filter to calls referenced by this message's `tool_use` content blocks | +| `[grouping]` | `'auto' \| 'none'` | `'auto'` | Auto-collapse adjacent same-name calls into a strip | +| `[groupSummary]` | `(name: string, count: number) => string` | built-in registry | Override the default strip label | + +## Default group summaries + +| Tool name shape | Default label | +|---|---| +| `search_*` | "Searched N sites" | +| `generate_*` | "Generated N items" | +| `read_*` | "Read N files" | +| `write_*` | "Wrote N files" | +| `list_*` | "Listed N items" | +| Anything else | "Called {name} N times" | + +## Per-tool templates + +Register a template per tool name (or `"*"` as a wildcard) — see [chat-tool-call-template](./chat-tool-call-template). + +```html + + + + + +``` + +When a per-tool template is registered for a name, calls of that name skip grouping and are rendered each through the template (the consumer takes responsibility for visual density). + +## Custom group summary + +```html + +``` + +```typescript +myGroupSummary = (name: string, count: number) => + name === 'fetch_user' ? `Fetched ${count} profiles` : `${name} × ${count}`; +``` + +## Disabling grouping + +```html + +``` + +Each call renders independently regardless of name adjacency. +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/website/content/docs/chat/components/chat-tool-calls.mdx +git commit -m "docs(chat): chat-tool-calls grouping + per-tool template registry + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 10.5: Update `chat.mdx` with the reasoning subsection + +**Files:** +- Modify: `apps/website/content/docs/chat/components/chat.mdx` + +- [ ] **Step 1: Append the reasoning section + tool-call template subsection** + +At the end of `apps/website/content/docs/chat/components/chat.mdx`, append: + +```mdx +## Reasoning + +When a model emits reasoning content (gpt-5 / o-series with `reasoning` blocks, Anthropic with `thinking` blocks, or any AG-UI agent emitting `REASONING_MESSAGE_*` events), the adapter populates `Message.reasoning` and `Message.reasoningDurationMs`. The `` composition automatically renders [``](./chat-reasoning) above the assistant response. No configuration required. + +While reasoning is streaming, the pill shows "Thinking…" with a pulse dot and the body auto-expands so the user sees content arrive in real time. Once response text begins, the pill collapses to "Thought for Ns" (e.g. "Thought for 4s"). + +## Tool-call templates + +Project a `` directly into `` to replace the default card UX for a specific tool name. The composition forwards the template into the inner [``](./chat-tool-calls). + +```html + + + + + +``` + +A `chatToolCallTemplate="*"` wildcard catches any unmapped tool name. See [chatToolCallTemplate](./chat-tool-call-template) for the directive reference. +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/website/content/docs/chat/components/chat.mdx +git commit -m "docs(chat): document reasoning slot + tool-call template projection in chat.mdx + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 10.6: Changelog callout + +**Files:** +- Create or modify: `apps/website/content/docs/chat/getting-started/changelog.mdx` (path inferred — confirm the correct page during execution) + +- [ ] **Step 1: Locate the existing changelog page** + +```bash +find apps/website/content/docs/chat -name "changelog*.mdx" 2>/dev/null +``` + +If a changelog page exists, edit it. If not, append a `## 0.0.19` section to whichever "What's new" / "Releases" page is canonical for chat (the implementer should `ls` and pick the right file). If no such page exists, create `apps/website/content/docs/chat/getting-started/changelog.mdx` with the body below as the first entry. + +- [ ] **Step 2: Add the entry** + +Add (or prepend) the following section to the changelog page: + +```mdx +## 0.0.19 + +### Reasoning + +- New `` primitive renders model reasoning content as a "Thinking…" / "Thought for Ns" pill, default-collapsed once streaming completes. Auto-rendered by `` when `Message.reasoning` is populated. +- New `Message.reasoning` and `Message.reasoningDurationMs` optional fields on the shared agent contract. Both adapters populate them: `@ngaf/langgraph` from `{type:'reasoning'}` / `{type:'thinking'}` content blocks, `@ngaf/ag-ui` from `REASONING_MESSAGE_*` events. + +### Tool-call templates + +- New `chatToolCallTemplate` directive registers per-tool-name templates inside ``. A literal `"*"` registers a wildcard catch-all. +- `` `[grouping]="'auto'"` (the default) auto-collapses sequential same-name tool calls into a labeled strip ("Searched 5 sites"). Pass `[grouping]="'none'"` to opt out. +- The legacy single-`` fallback inside `` is removed in favor of the named-template registry. Consumers wanting a catch-all use `chatToolCallTemplate="*"`. +- `` defaults to collapsed when `complete`. Pass `[defaultCollapsed]="false"` for always-expanded. +- New status pill (running spinner / done check / error glyph) with consistent visual chrome. +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/content/docs/chat/ +git commit -m "docs(chat): 0.0.19 changelog entry + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 11: Smoke + version bumps + PR + +### Task 11.1: Bump versions + +**Files:** +- Modify: `libs/chat/package.json` +- Modify: `libs/langgraph/package.json` +- Modify: `libs/ag-ui/package.json` + +- [ ] **Step 1: Bump `@ngaf/chat`** + +Edit `libs/chat/package.json` and change `"version": "0.0.18"` to `"version": "0.0.19"`. + +- [ ] **Step 2: Bump `@ngaf/langgraph`** + +Edit `libs/langgraph/package.json` and change `"version": "0.0.10"` to `"version": "0.0.11"`. + +- [ ] **Step 3: Bump `@ngaf/ag-ui`** + +Edit `libs/ag-ui/package.json` and change `"version": "0.0.2"` to `"version": "0.0.3"`. + +- [ ] **Step 4: Build everything** + +```bash +npx nx run-many --target=build --projects=licensing,render,chat,langgraph,ag-ui 2>&1 | tail -10 +``` + +Expected: all five projects build successfully. + +- [ ] **Step 5: Run all tests** + +```bash +npx nx run-many --target=test --projects=chat,langgraph,ag-ui 2>&1 | tail -15 +``` + +Expected: all three test suites pass. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/package.json libs/langgraph/package.json libs/ag-ui/package.json +git commit -m "chore: bump chat 0.0.19, langgraph 0.0.11, ag-ui 0.0.3 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 11.2: Smoke test in `~/tmp/ngaf` + +**Files:** none (validation only) + +- [ ] **Step 1: Pack the new tarballs** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/dist/libs/chat && /bin/rm -f *.tgz; npm pack 2>&1 | tail -1 +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/dist/libs/langgraph && /bin/rm -f *.tgz; npm pack 2>&1 | tail -1 +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/dist/libs/ag-ui && /bin/rm -f *.tgz; npm pack 2>&1 | tail -1 +``` + +Expected: three tarballs printed (`ngaf-chat-0.0.19.tgz`, `ngaf-langgraph-0.0.11.tgz`, `ngaf-ag-ui-0.0.3.tgz`). + +- [ ] **Step 2: Install all three into the smoke harness** + +```bash +cd ~/tmp/ngaf && npm i --no-save \ + /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/dist/libs/chat/ngaf-chat-0.0.19.tgz \ + /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/dist/libs/langgraph/ngaf-langgraph-0.0.11.tgz \ + /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/dist/libs/ag-ui/ngaf-ag-ui-0.0.3.tgz \ + 2>&1 | tail -3 +``` + +- [ ] **Step 3: Restart `ng serve` with a fresh `.angular/cache`** + +```bash +PID=$(lsof -iTCP:4200 -sTCP:LISTEN -n -P 2>/dev/null | tail -n +2 | awk '{print $2}' | head -1); kill $PID 2>/dev/null +sleep 2 +rm -rf /Users/blove/tmp/ngaf/.angular/cache +cd ~/tmp/ngaf && nohup npx ng serve --port 4200 > /tmp/ngaf-ng-serve.log 2>&1 & +disown +sleep 14 +tail -3 /tmp/ngaf-ng-serve.log +``` + +Expected: `ng serve` is running and `Application bundle generation complete` appears in the log. + +- [ ] **Step 4: Manually verify in the browser** + +Open http://localhost:4200, pick `gpt-5-mini` from the model picker, click the "Tell me about coral reefs" suggestion, and confirm: + +1. A "Thinking…" pill appears briefly (or "Thought for Ns" if reasoning was very fast). +2. The pill collapses once response text begins streaming. +3. Markdown bullets, headings, and code blocks render in the response (regression check from the prior phase). +4. No JSON arrays leak into the bubble. + +If any of the four checks fails, return to the relevant phase and fix before opening the PR. + +- [ ] **Step 5: Mark this task done (no commit — validation only)** + +### Task 11.3: Push + open PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin claude/chat-reasoning-and-tool-call-templates 2>&1 | tail -3 +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "chat reasoning + tool-call templates (chat 0.0.19, langgraph 0.0.11, ag-ui 0.0.3)" --body "$(cat <<'EOF' +## Summary + +Stacks on top of #191 (chat 0.0.18 + langgraph 0.0.10). + +### Reasoning (new) +- `` primitive renders model reasoning content as a collapsible "Thinking…" / "Thought for Ns" pill above the assistant response. Auto-rendered by `` when `Message.reasoning` is populated. +- `Message.reasoning` + `Message.reasoningDurationMs` optional fields on the shared agent contract. Both adapters populate them from provider-agnostic sources: LangGraph `{type:'reasoning'|'thinking'}` blocks, AG-UI `REASONING_MESSAGE_*` events. +- Shared `assertReasoningFixtureMessages` conformance test ensures both adapters produce identical Message[] from the same abstract event sequence. + +### Tool-call extension surface +- `chatToolCallTemplate` directive registers per-tool-name templates inside ``. A `"*"` wildcard catches unmapped names. +- `` auto-groups sequential same-name calls into a labeled strip ("Searched 5 sites"). `[grouping]="'none'"` opts out. +- `` defaults to collapsed when `complete`; running and errored cards stay expanded. Status pill (spinner / check / error glyph) replaces inline plaintext. +- `` re-projects `chatToolCallTemplate` directives into the inner ``. + +### FakeAgent +- `FakeAgent` gains an optional `reasoningTokens` constructor option for offline demos and integration tests. + +### Docs +- New `chat-reasoning.mdx`, `chat-tool-call-template.mdx`, `chat-tool-calls.mdx`. +- Updated `chat-tool-call-card.mdx`, `chat.mdx`. +- 0.0.19 changelog entry. + +Bumps `@ngaf/chat` 0.0.18 → 0.0.19, `@ngaf/langgraph` 0.0.10 → 0.0.11, `@ngaf/ag-ui` 0.0.2 → 0.0.3. + +## Test plan + +- [ ] Smoke against \`~/tmp/ngaf\` + LangGraph \`langgraph dev\` + gpt-5-mini at \`reasoning.effort='minimal'\`: reasoning pill appears + collapses after streaming. +- [ ] Smoke at \`reasoning.effort='high'\`: reasoning streams visibly while pill says "Thinking…", collapses on text start. +- [ ] Tool-call card renders collapsed after a tool completes (any graph that exercises a tool). +- [ ] Register a \`chatToolCallTemplate="search_web"\` in the smoke app; verify it replaces the default card. +- [ ] CI green. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: prints the new PR URL. + +- [ ] **Step 3: Wait for CI and merge** + +After CI completes (Library tests + Cockpit + Website + e2e), merge: + +```bash +gh pr merge --squash --delete-branch +``` + +If checks fail, investigate per the failed log and push a follow-up commit before merging. + +- [ ] **Step 4: Tag the release** + +After merge, on `main`: + +```bash +git checkout main && git pull origin main +git tag chat-v0.0.19 +git tag langgraph-v0.0.11 +git tag ag-ui-v0.0.3 +git push origin chat-v0.0.19 langgraph-v0.0.11 ag-ui-v0.0.3 +``` + +--- + +## Plan self-review notes + +- **Spec coverage:** every numbered section of the spec maps to one or more tasks above (§2 Architecture → Task 1.1; §3 chat-reasoning → Phase 2; §4 directive → Phase 3; §5 tool-calls → Phase 4; §6 tool-call-card → Phase 5; §7 composition → Phase 9; §8 bridges → Phase 6 + 7; §9 testing harnesses → Tasks 7.2 (fake-agent) + Task 8.1 (mock-agent shape widening implicit via Message change); §10 docs → Phase 10; §11 versioning → Task 11.1; §12 deferred — out of scope; §13 smoke → Task 11.2). The mock-agent change in §9.1 of the spec is implicit: `Message` widening at Task 1.1 makes `reasoning`/`reasoningDurationMs` available everywhere with no further code change required, so no dedicated task is needed. + +- **No placeholders.** Every step contains exact code, exact paths, exact commands, exact expected output. + +- **Type consistency:** `Message.reasoning?: string`, `Message.reasoningDurationMs?: number` defined in Task 1.1 and consumed everywhere. `ToolCallStatus` re-used (not redefined). `ChatToolCallTemplateContext` defined in Task 3.2 and referenced in Task 10.2. `summarizeGroup` exported from `group-summary.ts` (Task 4.2) and consumed in `chat-tool-calls.component.ts` (Task 4.3) and tested in Task 4.1. + +- **Hard constraint adherence:** plan body, code samples, commit messages, and PR body contain no references to `copilotkit`, `chatgpt`, `chatbot-kit`, or similar. diff --git a/docs/superpowers/specs/2026-03-18-chat-agent-design.md b/docs/superpowers/specs/2026-03-18-chat-agent-design.md index 353185c01..bb7e8702d 100644 --- a/docs/superpowers/specs/2026-03-18-chat-agent-design.md +++ b/docs/superpowers/specs/2026-03-18-chat-agent-design.md @@ -121,7 +121,7 @@ const chat = agent<{ messages: BaseMessage[] }>({ | Variable | Required | Description | |---|---|---| | `OPENAI_API_KEY` | Yes | OpenAI API key | -| `OPENAI_MODEL` | No | Model name (default: `gpt-5-mini`). Change to `gpt-4o-mini` if `gpt-5-mini` is unavailable in your account | +| `OPENAI_MODEL` | No | Model name (default: `gpt-5-mini`). | | `LANGSMITH_API_KEY` | Deploy only | Required for `langgraph deploy`. Not needed for local `langgraph dev` | | `LANGSMITH_TRACING` | No | Set to `true` to enable LangSmith trace logging during local dev | | `LANGSMITH_PROJECT` | No | Project name in LangSmith UI (default: `angular-example`) | diff --git a/docs/superpowers/specs/2026-05-03-chat-reasoning-and-tool-call-templates-design.md b/docs/superpowers/specs/2026-05-03-chat-reasoning-and-tool-call-templates-design.md new file mode 100644 index 000000000..dbab316cc --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-chat-reasoning-and-tool-call-templates-design.md @@ -0,0 +1,363 @@ +# Chat Reasoning + Tool Call Templates — Design Specification + +**Date:** 2026-05-03 +**Status:** Draft, pending user review +**Phase:** B2 in the post-0.0.18 roadmap (B = tool-call & reasoning UX; B2 = polish + reasoning, citations deferred to a later sub-phase) +**Targets:** `@ngaf/chat` 0.0.19, `@ngaf/langgraph` 0.0.11, `@ngaf/ag-ui` next patch + +--- + +## 1. Goals + +Surface assistant reasoning content as a first-class UI affordance, and turn tool-call rendering into a CopilotKit-style extension surface while keeping a polished default that matches what users see in ChatGPT-style products. + +Concretely: + +- Reasoning content emitted by gpt-5 / o-series / Anthropic thinking-enabled models renders as a collapsible "Thinking…" / "Thought for Ns" pill above the assistant response, default-collapsed once the response starts. +- Tool-call cards default-collapse when complete; the at-a-glance state pill (running / done / error) gets consistent visual chrome. +- Sequential same-name tool calls auto-group into a single labeled strip ("Searched 5 sites"). +- Consumers can fully replace the default card UX per tool name via a `chatToolCallTemplate` directive — turning e.g. `search_images` into an image grid, `place_order` into a confirmation card, without forking the primitive. +- Both adapters (`@ngaf/langgraph` and `@ngaf/ag-ui`) populate the same `Message.reasoning` field; downstream UI is provider-neutral. + +--- + +## 2. Architecture + +### 2.1 Data flow + +``` +LangGraph SSE ─┐ ┌─ + ├→ adapter ─→ Message {role,content, ┤ +AG-UI events ───┘ reasoning?, …, extra} ├─ + └─ + └─ optional +``` + +Two adapter paths converge on the shared `Message` interface in `@ngaf/chat/agent/message.ts`. The chat composition reads only that shape — it never sees provider-specific blocks, events, or schemas. + +### 2.2 `Message` interface additions + +```typescript +// libs/chat/src/lib/agent/message.ts +export interface Message { + // existing fields… + /** Reasoning text emitted by the model before/alongside the visible response. + * Populated by adapters from {type:'reasoning'}/{type:'thinking'} content + * blocks (LangGraph) or REASONING_MESSAGE_* events (AG-UI). */ + reasoning?: string; + /** Wall-clock duration of the reasoning phase in milliseconds. Populated by + * the adapter when start and end timestamps are both known. */ + reasoningDurationMs?: number; +} +``` + +Both fields are optional — existing code reading `Message` is unaffected. + +### 2.3 Provider neutrality + +`Message.reasoning` is always a plain string. Provider-specific shape (encrypted-thinking blocks, multi-step reasoning summaries, redacted ranges) is absorbed by the adapters and not exposed to UI primitives. Future enhancements (multi-step trace, token counts, redaction badges) are additive on `Message` or attached via `Message.extra` — never breaking the v1 string contract. + +--- + +## 3. New primitive: `` + +### 3.1 Selector & API + +```typescript +@Component({ selector: 'chat-reasoning', standalone: true, changeDetection: OnPush }) +export class ChatReasoningComponent { + readonly content = input.required(); + readonly isStreaming = input(false); + readonly durationMs = input(undefined); + readonly label = input(undefined); + readonly defaultExpanded = input(false); +} +``` + +Slot: `[chatReasoningLabel]` content-projection for fully custom labels (default rendering covers the common case). + +### 3.2 Visual states + +| State | Pill label | Body | +|---|---|---| +| `isStreaming === true` | "Thinking…" with subtle pulsing dot | Auto-expanded; renders `[content]` through `` | +| Idle, has `durationMs` | `"Thought for {formatDuration(ms)}"` | Hidden by default; click to expand | +| Idle, no `durationMs` | "Show reasoning" | Hidden by default; click to expand | + +`formatDuration`: +- < 1 s → `"<1s"` +- 1 s ≤ d < 60 s → `"Ns"` (e.g. `"4s"`) +- d ≥ 60 s → `"Nm Ms"` (e.g. `"1m 12s"`) + +### 3.3 State management + +Internal `expanded = signal(defaultExpanded)`. Transitions: + +- `isStreaming` becoming `true` → force `expanded = true` (so streaming text is visible). +- `isStreaming` going `true → false` → leave `expanded` as user left it (don't force-collapse mid-read). +- `isStreaming` going `false → true` after a prior idle period (e.g. follow-up turn that re-uses the component) → reset to expanded. +- User click → toggle, and the user choice persists for the lifetime of this primitive instance. + +Body re-uses `` for markdown rendering; reasoning text often contains lists, code, or step labels. + +### 3.4 Styling + +- Muted text color (`var(--ngaf-chat-text-muted)`). +- Thin left border on expanded body (matches blockquote pattern). +- Pill chrome same as existing chat surfaces (rounded rect, surface-alt bg). +- No new design tokens. + +### 3.5 Tests + +- Renders correct label per state (streaming / idle-with-duration / idle-without). +- `formatDuration` boundary cases (`<1s`, `1s`, `59s`, `60s → "1m 0s"`, `125s → "2m 5s"`). +- Click toggles expansion; user choice persists across `isStreaming` transitions. +- Hides itself when `[content]` is empty (`:host(:not([data-has-content])) { display: none }`). +- Force-expands when `isStreaming` is `true`. +- Streams content through `` (markdown renders inside the panel). + +--- + +## 4. New directive: `chatToolCallTemplate` + +### 4.1 Surface + +```html + + + + + + + + +``` + +### 4.2 Implementation + +```typescript +@Directive({ selector: '[chatToolCallTemplate]', standalone: true }) +export class ChatToolCallTemplateDirective { + readonly chatToolCallTemplate = input.required(); // tool name + readonly templateRef = inject(TemplateRef); +} + +interface ChatToolCallTemplateContext { + $implicit: ToolCall; // the tool call + status: ToolCallStatus; // 'pending' | 'running' | 'complete' | 'error' +} +``` + +`` collects directives via `contentChildren(ChatToolCallTemplateDirective)`, builds a `Map`, dispatches per call. Falls back to `` when no template is registered for a given tool name. + +--- + +## 5. Augmented: `` + +### 5.1 New inputs + +```typescript +readonly grouping = input<'auto' | 'none'>('auto'); +readonly groupSummary = input<((name: string, count: number) => string) | undefined>(undefined); +``` + +The legacy single `` content-child fallback (no name binding) is removed in favor of the named-template directive — consumers who want a catch-all register `chatToolCallTemplate="*"` (wildcard) and the registry uses it for any unmapped name. + +### 5.2 Grouping behavior + +When `grouping === 'auto'`, walk the tool-call list once. Adjacent calls with the same `name` form a group: + +- **Group size 1** → render as a single card (or per-tool template if registered). +- **Group size ≥ 2 + per-tool-name template registered** → render each call through that template (consumer takes responsibility for visual density). +- **Group size ≥ 2 + no per-tool template** → render a single collapsible strip with header summary text. Expanding reveals the individual ``s. +- A different `name` resets the grouping. `[search_web, search_web, read_file, search_web]` → 3 entries: a 2-group, a 1, a 1. + +### 5.3 Default summary text + +A small registry keyed by tool-name shape: + +| Pattern | Default summary | +|---|---| +| `search_*` | "Searched N {pluralize}" | +| `generate_*` | "Generated N {pluralize}" | +| `read_*` / `write_*` / `list_*` | "Called {name} N times" | +| Any other | "Called {name} N times" | + +`[groupSummary]` callback overrides the registry per-instance. + +### 5.4 Tests + +- Grouped: 3 sequential `search_web` calls → one strip + 3 cards on expand. +- Mixed: `search_web` + `read_file` + `search_web` → 3 entries (groups don't span name boundaries). +- Per-tool template wins: `chatToolCallTemplate="search_web"` registered → all `search_web` calls go through template, no card. +- Wildcard catch-all: `chatToolCallTemplate="*"` registered + a per-tool template for `search_web` → `search_web` calls go through their specific template; everything else goes through `*`. +- `[grouping]="'none'"`: every call independent (no auto-grouping). +- `[groupSummary]` callback overrides the default registry. + +--- + +## 6. Augmented: `` + +### 6.1 Status pill + +Replaces inline plaintext status with a consistent pill rendered next to the tool name: + +| State | Visual | aria-label | +|---|---|---| +| `running` | spinner icon (CSS `@keyframes` rotation) | "Running" | +| `done` | check icon (success color) | "Completed" | +| `error` | exclamation icon (error color) | "Failed" | + +All three pills share the same chrome (rounded rect, surface-alt bg, 11px font, semibold). Equal visual weight regardless of state. + +### 6.2 Default-collapsed when done + +```typescript +readonly defaultCollapsed = input(true); +``` + +- `running` → expanded (visible "in progress" affordance). +- `error` → expanded (failure detail without a click). +- `done` → collapsed when `defaultCollapsed === true`. +- Header is always clickable to toggle. Once a user manually toggles, manual choice wins for the lifetime of that card instance — subsequent status changes don't override. + +### 6.3 Tests + +- Renders running spinner / done check / error badge with correct aria-labels. +- Default-collapsed when status is `done`; expanded for `running` and `error`. +- Click on header toggles open/closed. +- After manual toggle, card respects user choice across status changes. +- `[defaultCollapsed]="false"` always-expanded mode. + +--- + +## 7. `` composition wiring + +### 7.1 Reasoning slot + +In the assistant message branch of ``'s template, render `` between any tool-calls/subagents block and the response markdown when `message.reasoning` is non-empty: + +```html +@if (message.reasoning) { + +} +``` + +`isReasoningStreaming(message)` is a small helper computed from agent state: `true` when this message is the streaming tail AND no response text has arrived yet. Once response tokens start, `isStreaming` flips to `false` and the panel collapses (per its internal logic in §3.3). + +### 7.2 Tool-call template forwarding + +Consumers projecting `` directives directly into `` need them forwarded into the inner ``. Pattern matches existing `[chatInputModelSelect]` / `[chatWelcomeSuggestions]` slot forwarding: outer `` accepts content via projection, inner `` `contentChildren()` picks them up by directive class. The templates are projected via `` inside the message-list template. + +### 7.3 Public API additions to `@ngaf/chat` + +Re-exports from `public-api.ts`: + +- `ChatReasoningComponent` +- `ChatToolCallTemplateDirective` +- `ChatToolCallTemplateContext` (interface) +- `formatDuration` utility + +--- + +## 8. Bridge changes + +### 8.1 `@ngaf/langgraph` (`stream-manager.bridge.ts` + `agent.fn.ts → toMessage`) + +1. New `extractReasoning(content)` helper, parallel to `extractText`. Walks complex-content arrays, accumulates text from `{type:'reasoning'}` and `{type:'thinking'}` blocks (provider-agnostic), skips everything else. +2. New `accumulateReasoning(existing, incoming)` parallel to `accumulateContent` — same superset / pure-delta append semantics, returns string. +3. `mergeMessages` extended: when merging an incoming AI chunk into an existing slot, additionally accumulate reasoning content via `accumulateReasoning(existing.reasoning, incoming.reasoning)` and write it to the merged slot. +4. New `reasoningTimingMap = new Map()`. First chunk with reasoning content for a given id sets `startedAt`. First chunk where response text appears (and reasoning is non-empty) sets `endedAt`. On final canonical message merge, compute `reasoningDurationMs = endedAt - startedAt` if both timestamps exist. The map is cleared on `resetThreadState()` (thread switch) and on bridge teardown so it doesn't leak across thread boundaries. +5. `toMessage` (in `agent.fn.ts`) reads accumulated reasoning + the timing map, returns `{…, reasoning, reasoningDurationMs}`. + +### 8.2 `@ngaf/ag-ui` (`to-agent.ts`) + +1. New event handlers: + - `REASONING_MESSAGE_START` — record `startedAt` for the active assistant message; initialize its `reasoning` string. + - `REASONING_MESSAGE_CONTENT` / `REASONING_MESSAGE_CHUNK` — append to the active message's `reasoning` string. + - `REASONING_MESSAGE_END` — record `endedAt`; compute and write `reasoningDurationMs`. +2. The active-message tracking already exists for text-message events; reasoning piggy-backs on the same `currentMessageId` pointer. +3. Update the `messages` signal whenever reasoning state changes for the active message (same pattern as text content updates). + +### 8.3 Conformance test + +A shared `reasoning-fixture.ts` defines an abstract event sequence ("reasoning chunk arrives", "text chunk arrives") and the expected `Message[]` output. Both adapter spec suites translate the abstract sequence into their respective wire format and assert the same `Message[]` shape (including `reasoning` text and `reasoningDurationMs >= 0`). One fixture, two adapters — keeps the populating logic honest across implementations. + +--- + +## 9. Testing harnesses + +Three test/mock surfaces beyond the runtime adapters. None require behavior changes; the `Message` widening makes the new fields available automatically. + +- **`libs/chat/src/lib/testing/mock-agent.ts`** — generic `Message[]` mock for chat-library tests. No code change. Spec suites that exercise the new behavior seed mock messages with `reasoning` populated. +- **`libs/langgraph/src/lib/testing/mock-langgraph-agent.ts`** — exposes `messages` and `langGraphMessages` signals. Same widening; example added to docs showing how to seed reasoning text. +- **`libs/ag-ui/src/lib/testing/fake-agent.ts`** — canned event emitter. Gains an optional `reasoningTokens?: string[]` constructor option that, when provided, emits `REASONING_MESSAGE_START` → N × `REASONING_MESSAGE_CONTENT` → `REASONING_MESSAGE_END` *before* the existing text token sequence. +- **`libs/ag-ui/src/lib/testing/provide-fake-ag-ui-agent.ts`** — pass-through of the new `reasoningTokens` option. + +--- + +## 10. Documentation + +Per-component MDX files under `apps/website/content/docs/chat/components/`. + +### 10.1 New docs + +- **`chat-reasoning.mdx`** — full primitive reference: API table for all inputs (`[content]`, `[isStreaming]`, `[durationMs]`, `[label]`, `[defaultExpanded]`), three visual states with code examples, the `formatDuration` helper, the `[chatReasoningLabel]` slot, integration example showing automatic rendering by `` plus a standalone usage example. +- **`chat-tool-call-template.mdx`** — directive reference. Selector + template context shape (`let-call`, `let-status`), worked examples (search results card, image generation card), interaction with `[grouping]`. + +### 10.2 Updated docs + +- **`chat-tool-calls.mdx`** — adds `[grouping]` input, `[groupSummary]` callback, the `chatToolCallTemplate` extension pattern (cross-link to its dedicated page), grouping behavior table. +- **`chat-tool-call-card.mdx`** — adds `[defaultCollapsed]` input, status pill visual reference (running/done/error), default-state behavior table per status. +- **`chat.mdx`** — short "Reasoning" subsection covering automatic `` rendering and the tool-call template projection example. + +### 10.3 Changelog + +A "What's new in chat 0.0.19" callout in the appropriate changelog page (location confirmed during planning) covering: reasoning auto-display, default-collapsed tool-call cards, per-tool template extension via `chatToolCallTemplate` directive, named-template registry replacing the legacy single-template fallback, automatic grouping of sequential same-name calls. + +### 10.4 Hard constraint + +Never reference any chat-UI library this work was inspired by. No `copilotkit` / `chatgpt` / `chatbot-kit` references in code, comments, commits, PR bodies, or docs. The aesthetic and extensibility patterns described here are independently arrived at. + +--- + +## 11. Versioning & release + +- `@ngaf/chat` 0.0.18 → **0.0.19** — new primitive, new directive, augmented primitives, new `Message` fields, tool-call rendering reshaped (default-collapsed cards, named-template registry replacing the legacy single-template fallback). +- `@ngaf/langgraph` 0.0.10 → **0.0.11** — bridge extracts reasoning, populates timing. +- `@ngaf/ag-ui` current → next patch — reasoning event handlers + fake-agent reasoning option. + +Single PR, single tag set: `chat-v0.0.19`, `langgraph-v0.0.11`, `ag-ui-v`. Phased commits inside the PR. + +Branch: `claude/chat-reasoning-and-tool-call-templates`, fresh from `origin/main` after #191 merges. + +These are pre-1.0 patch releases. No backward-compatibility guarantees — the legacy single-template fallback in `` is removed (replaced by the wildcard `chatToolCallTemplate="*"` pattern), and `` defaults to collapsed-when-done. Consumers update at the same patch boundary. + +--- + +## 12. Out of scope (deferred) + +- **Citations / sources rendering** — next sub-phase after this one (per the B2-then-citations plan). +- **D — generative UI / structured streaming output** (`` polish). +- **A — thread list / conversation continuity.** +- **C — file/image attachments / voice input.** +- **Multi-step reasoning visualization** — one reasoning string is the v1 contract. +- **Reasoning effort surface in UI** — developer-side only (`reasoning_effort` in adapter state). +- **Per-step reasoning timing or token counts** beyond `reasoningDurationMs`. + +--- + +## 13. Smoke test plan + +- Regression: `~/tmp/ngaf` against `langgraph dev` + gpt-5-mini at default `reasoning.effort='minimal'` — verify reasoning pill appears with "Thought for Ns" after streaming completes. +- Regression: same with effort raised to `'high'` — verify reasoning streams visibly while pill says "Thinking…", collapses to "Thought for Ns" when text starts. +- Regression: tool-call card renders collapsed after a tool completes (graph that exercises a tool). +- Smoke: register a `chatToolCallTemplate="search_web"` in the smoke app, verify it replaces the default card. diff --git a/libs/chat/package.json b/libs/chat/package.json index b08883ab9..926181e2e 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.17", + "version": "0.0.18", "exports": { ".": { "types": "./index.d.ts", diff --git a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts index 2a0ff7193..ddbbeb14e 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -22,10 +22,20 @@ describe('ChatComponent', () => { expect(messageContent(msg)).toBe('hello world'); }); - it('messageContent serializes array content to JSON', () => { + it('messageContent extracts visible text from complex-content arrays', () => { const msg = new AIMessage({ content: [{ type: 'text', text: 'hi' }] }); - const result = messageContent(msg); - expect(result).toContain('text'); + expect(messageContent(msg)).toBe('hi'); + }); + + it('messageContent concatenates multiple text blocks and skips reasoning blocks', () => { + const msg = new AIMessage({ + content: [ + { type: 'reasoning', text: 'thinking…' }, + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' world' }, + ], + }); + expect(messageContent(msg)).toBe('Hello world'); }); it('has a template defined on the component metadata', () => { diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 9bbef7f39..8fac0466f 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -1,7 +1,7 @@ // libs/chat/src/lib/compositions/chat/chat.component.ts // SPDX-License-Identifier: MIT import { - Component, ChangeDetectionStrategy, input, output, computed, effect, viewChild, ElementRef, + Component, ChangeDetectionStrategy, input, model, output, computed, effect, viewChild, ElementRef, DestroyRef, inject, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -26,6 +26,7 @@ import { ChatToolCallsComponent } from '../../primitives/chat-tool-calls/chat-to import { ChatSubagentsComponent } from '../../primitives/chat-subagents/chat-subagents.component'; import { ChatMessageActionsComponent } from '../../primitives/chat-message-actions/chat-message-actions.component'; import { ChatWelcomeComponent } from '../../primitives/chat-welcome/chat-welcome.component'; +import { ChatSelectComponent, type ChatSelectOption } from '../../primitives/chat-select/chat-select.component'; import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; import { messageContent } from '../shared/message-utils'; @@ -41,7 +42,7 @@ import type { ChatRenderEvent } from './chat-render-event'; ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent, ChatInterruptComponent, ChatThreadListComponent, ChatGenerativeUiComponent, ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent, - ChatMessageActionsComponent, ChatWelcomeComponent, + ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, ` @@ -98,7 +99,16 @@ import type { ChatRenderEvent } from './chat-render-event'; template: ` @if (showWelcome()) { - + + @if (modelOptions().length > 0) { + + } + @@ -182,7 +192,20 @@ import type { ChatRenderEvent } from './chat-render-event';
- + + @if (modelOptions().length > 0) { + + } @else { + + + + } +
@@ -199,6 +222,17 @@ export class ChatComponent { readonly activeThreadId = input(''); readonly welcomeDisabled = input(false); + /** + * High-level model-picker API. When `modelOptions` is non-empty, the chat + * composition renders a `` inside the input pill (in BOTH + * welcome and conversation modes), wired to the two-way `selectedModel` + * model. Consumers who want full control should leave `modelOptions` + * empty and project a `` themselves. + */ + readonly modelOptions = input([]); + readonly selectedModel = model(''); + readonly modelPickerPlaceholder = input('Choose a model'); + readonly showWelcome = computed(() => { if (this.welcomeDisabled()) return false; const a = this.agent() as unknown as { isThreadLoading?: () => boolean }; diff --git a/libs/chat/src/lib/compositions/shared/message-utils.ts b/libs/chat/src/lib/compositions/shared/message-utils.ts index 773a4ae4a..6d184abae 100644 --- a/libs/chat/src/lib/compositions/shared/message-utils.ts +++ b/libs/chat/src/lib/compositions/shared/message-utils.ts @@ -3,10 +3,38 @@ import type { BaseMessage } from '@langchain/core/messages'; /** * Extracts a human-readable string from a message's content. - * Handles string content directly; serializes structured (array) content to JSON. + * + * `BaseMessage.content` is `string | MessageContentComplex[]`. Reasoning- + * capable models (OpenAI gpt-5/o-series, Anthropic) emit complex arrays of + * typed blocks: `{type:'text',text}`, `{type:'reasoning',...}`, tool-use + * blocks, etc. We render only the visible text portions and skip anything + * else. Stringifying the whole array would dump raw JSON like + * `[{"type":"text",...}]` into the chat bubble. */ export function messageContent(message: BaseMessage): string { - const content = message.content; + return extractText(message.content); +} + +function extractText(content: unknown): string { if (typeof content === 'string') return content; - return JSON.stringify(content); + if (!Array.isArray(content)) return ''; + let out = ''; + for (const block of content) { + if (typeof block === 'string') { + out += block; + continue; + } + if (!isRecord(block)) continue; + const t = block['type']; + if (t === 'text' || t === 'output_text' || t === undefined) { + const text = block['text']; + if (typeof text === 'string') out += text; + } + // Skip reasoning, tool_use, image, etc. — not chat-bubble content. + } + return out; +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); } diff --git a/libs/chat/src/lib/streaming/markdown-render.ts b/libs/chat/src/lib/streaming/markdown-render.ts index a486f800a..b471718ff 100644 --- a/libs/chat/src/lib/streaming/markdown-render.ts +++ b/libs/chat/src/lib/streaming/markdown-render.ts @@ -10,7 +10,12 @@ function ensureMarkedLoaded(): void { // Eagerly kick off the dynamic import so it's ready for subsequent calls void import('marked') .then((m) => { - markedParse = (src: string) => (m as any).marked.parse(src, { async: false }) as string; + // GFM: enables tables, strikethrough, autolinks, task lists. + // breaks: single \n becomes
. LLM chat output frequently uses + // soft line breaks for visual structure where stricter markdown + // would treat them as continuation. Matching common chat UX. + const opts = { async: false, gfm: true, breaks: true } as const; + markedParse = (src: string) => (m as any).marked.parse(src, opts) as string; }) .catch(() => { markedParse = null; diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.ts index ee5f9b5bb..abb7839ad 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.ts @@ -5,6 +5,7 @@ import { ChangeDetectionStrategy, DestroyRef, ElementRef, + ViewEncapsulation, effect, inject, input, @@ -13,6 +14,7 @@ import { import { DomSanitizer } from '@angular/platform-browser'; import { renderMarkdownToString } from './markdown-render'; import { isTraceEnabled, trace } from './trace'; +import { CHAT_MARKDOWN_STYLES } from '../styles/chat-markdown.styles'; /** * Renders markdown content via marked.parse + sanitized innerHTML, coalesced @@ -26,8 +28,16 @@ import { isTraceEnabled, trace } from './trace'; selector: 'chat-streaming-md', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, + // Disable emulated view encapsulation. The component sets its content via + // `innerHTML` (Angular's sanitized markdown render), so the resulting DOM + // nodes never carry the `_ngcontent-xxx` attribute that emulated styles + // require to match descendants. Without this, `chat-streaming-md ul` and + // friends in CHAT_MARKDOWN_STYLES never apply, and bullets/headings/code + // blocks render unstyled. We scope every selector to `chat-streaming-md` + // in CHAT_MARKDOWN_STYLES so the rules don't leak globally. + encapsulation: ViewEncapsulation.None, template: '', - styles: `:host { display: block; }`, + styles: CHAT_MARKDOWN_STYLES, }) export class ChatStreamingMdComponent { readonly content = input.required(); diff --git a/libs/chat/src/lib/styles/chat-markdown.styles.ts b/libs/chat/src/lib/styles/chat-markdown.styles.ts index c745a568c..d8f9495ab 100644 --- a/libs/chat/src/lib/styles/chat-markdown.styles.ts +++ b/libs/chat/src/lib/styles/chat-markdown.styles.ts @@ -1,39 +1,116 @@ // libs/chat/src/lib/styles/chat-markdown.styles.ts // SPDX-License-Identifier: MIT +// +// Scoped to the `chat-streaming-md` element selector (not `:host`) because +// the component renders markdown via innerHTML and uses +// `ViewEncapsulation.None` — emulated encapsulation would skip these rules +// since innerHTML-injected nodes don't carry `_ngcontent-xxx` attributes. +// Keeping selectors prefixed with the element name preserves locality so +// these rules don't leak to other markup on the page. +// +// Covers the CommonMark + GFM surface: headings, paragraphs, links, lists +// (bullet/ordered/task), code (inline + fenced), blockquotes, horizontal +// rules, tables, images, bold (`strong`), italic (`em`), strikethrough +// (`del`/`s`). export const CHAT_MARKDOWN_STYLES = ` - :host { display: block; color: var(--ngaf-chat-text); line-height: var(--ngaf-chat-line-height); } - :host h1, :host h2, :host h3, :host h4, :host h5, :host h6 { font-weight: bold; line-height: 1.2; margin: 0 0 1rem; } - :host h1 { font-size: 1.5em; } - :host h2 { font-size: 1.25em; font-weight: 600; } - :host h3 { font-size: 1.1em; } - :host h4 { font-size: 1em; } - :host p { margin: 0 0 1rem; line-height: 1.75; font-size: var(--ngaf-chat-font-size); } - :host p:last-child { margin-bottom: 0; } - :host a { color: var(--ngaf-chat-primary); text-decoration: underline; } - :host ul, :host ol { margin: 0 0 1rem; padding-left: 1.25rem; } - :host code { + chat-streaming-md { display: block; color: var(--ngaf-chat-text); line-height: var(--ngaf-chat-line-height); } + + /* Headings */ + chat-streaming-md h1, chat-streaming-md h2, chat-streaming-md h3, chat-streaming-md h4, chat-streaming-md h5, chat-streaming-md h6 { + font-weight: 600; + line-height: 1.25; + margin: 1.25rem 0 0.75rem; + } + chat-streaming-md h1:first-child, chat-streaming-md h2:first-child, chat-streaming-md h3:first-child, + chat-streaming-md h4:first-child, chat-streaming-md h5:first-child, chat-streaming-md h6:first-child { margin-top: 0; } + chat-streaming-md h1 { font-size: 1.5em; font-weight: 700; } + chat-streaming-md h2 { font-size: 1.25em; } + chat-streaming-md h3 { font-size: 1.1em; } + chat-streaming-md h4 { font-size: 1em; } + chat-streaming-md h5, chat-streaming-md h6 { font-size: 0.95em; color: var(--ngaf-chat-text-muted); } + + /* Paragraphs and inline emphasis */ + chat-streaming-md p { margin: 0 0 0.75rem; line-height: 1.6; font-size: var(--ngaf-chat-font-size); } + chat-streaming-md p:last-child { margin-bottom: 0; } + chat-streaming-md strong, chat-streaming-md b { font-weight: 700; } + chat-streaming-md em, chat-streaming-md i { font-style: italic; } + chat-streaming-md del, chat-streaming-md s { text-decoration: line-through; color: var(--ngaf-chat-text-muted); } + chat-streaming-md mark { background: var(--ngaf-chat-surface-alt); padding: 0 2px; border-radius: 2px; } + chat-streaming-md sub { font-size: 0.75em; vertical-align: sub; } + chat-streaming-md sup { font-size: 0.75em; vertical-align: super; } + + /* Links */ + chat-streaming-md a { color: var(--ngaf-chat-primary); text-decoration: underline; text-underline-offset: 2px; } + chat-streaming-md a:hover { text-decoration-thickness: 2px; } + + /* Lists (CommonMark + GFM task lists) */ + chat-streaming-md ul, chat-streaming-md ol { margin: 0 0 0.75rem; padding-left: 1.5rem; } + chat-streaming-md ul { list-style: disc outside; } + chat-streaming-md ol { list-style: decimal outside; } + chat-streaming-md ul ul { list-style: circle outside; } + chat-streaming-md ul ul ul { list-style: square outside; } + chat-streaming-md li { margin: 0.2rem 0; } + chat-streaming-md li::marker { color: var(--ngaf-chat-text-muted); } + chat-streaming-md li > p { margin: 0 0 0.25rem; } + chat-streaming-md li > ul, chat-streaming-md li > ol { margin: 0.25rem 0 0; } + /* GFM task lists: marked emits
  • ... */ + chat-streaming-md li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.25rem; } + chat-streaming-md li > input[type="checkbox"] { margin-right: 0.5rem; vertical-align: middle; } + + /* Code (inline + fenced) */ + chat-streaming-md code { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); - padding: 1px 4px; + padding: 1px 5px; border-radius: 4px; font-family: var(--ngaf-chat-font-mono); font-size: 0.9em; } - :host pre { + chat-streaming-md pre { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); - padding: 12px; + padding: 12px 14px; border-radius: var(--ngaf-chat-radius-card); overflow-x: auto; font-family: var(--ngaf-chat-font-mono); font-size: var(--ngaf-chat-font-size-sm); - margin: 0 0 1rem; + line-height: 1.5; + margin: 0 0 0.75rem; } - :host pre code { background: transparent; padding: 0; border-radius: 0; } - :host blockquote { + chat-streaming-md pre code { background: transparent; padding: 0; border-radius: 0; font-size: inherit; } + + /* Blockquote */ + chat-streaming-md blockquote { border-left: 3px solid var(--ngaf-chat-separator); - padding-left: 12px; - margin: 0 0 1rem; + padding: 0.25rem 0 0.25rem 12px; + margin: 0 0 0.75rem; color: var(--ngaf-chat-text-muted); } + chat-streaming-md blockquote > :last-child { margin-bottom: 0; } + + /* Horizontal rule */ + chat-streaming-md hr { + border: none; + border-top: 1px solid var(--ngaf-chat-separator); + margin: 1rem 0; + } + + /* Tables (GFM) */ + chat-streaming-md table { + border-collapse: collapse; + margin: 0 0 0.75rem; + width: 100%; + font-size: 0.95em; + } + chat-streaming-md thead { background: var(--ngaf-chat-surface-alt); } + chat-streaming-md th, chat-streaming-md td { + border: 1px solid var(--ngaf-chat-separator); + padding: 6px 10px; + text-align: left; + vertical-align: top; + } + chat-streaming-md th { font-weight: 600; } + + /* Media */ + chat-streaming-md img { max-width: 100%; height: auto; border-radius: 6px; } `; diff --git a/libs/chat/src/lib/styles/chat-welcome.styles.ts b/libs/chat/src/lib/styles/chat-welcome.styles.ts index 7118e3b84..d27cffcfd 100644 --- a/libs/chat/src/lib/styles/chat-welcome.styles.ts +++ b/libs/chat/src/lib/styles/chat-welcome.styles.ts @@ -50,39 +50,40 @@ export const CHAT_WELCOME_STYLES = ` .chat-welcome__suggestions { width: 100%; display: flex; - flex-direction: column; - align-items: stretch; - gap: 0; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin-top: 4px; } .chat-welcome__suggestions:empty { display: none; } `; export const CHAT_WELCOME_SUGGESTION_STYLES = ` - :host { display: block; width: 100%; } + :host { display: inline-block; } .chat-welcome-suggestion { - width: 100%; - display: flex; + display: inline-flex; align-items: center; - gap: 0.75rem; - padding: 12px 14px; - background: transparent; - border: 0; - border-bottom: 1px solid var(--ngaf-chat-separator); + gap: 0.5rem; + padding: 10px 16px; + background: var(--ngaf-chat-surface); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 9999px; color: var(--ngaf-chat-text); font-family: inherit; font-size: var(--ngaf-chat-font-size-sm); - text-align: left; + text-align: center; cursor: pointer; - transition: background 150ms ease; + transition: background 150ms ease, border-color 150ms ease, transform 120ms ease; + } + .chat-welcome-suggestion:hover { + background: var(--ngaf-chat-surface-alt); + border-color: var(--ngaf-chat-text-muted); } - .chat-welcome-suggestion:hover { background: var(--ngaf-chat-surface-alt); } + .chat-welcome-suggestion:active { transform: scale(0.98); } .chat-welcome-suggestion:focus-visible { outline: 2px solid var(--ngaf-chat-text-muted); - outline-offset: -2px; - } - .chat-welcome-suggestion__label { flex: 1 1 auto; } - .chat-welcome-suggestion__chevron { - color: var(--ngaf-chat-text-muted); - font-size: 1.1em; + outline-offset: 2px; } + .chat-welcome-suggestion__label { white-space: nowrap; } + .chat-welcome-suggestion__chevron { display: none; } `; diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index f1cde13b0..5c2f23a8d 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.9", + "version": "0.0.10", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index ea96b0ebc..61a031047 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { inject, DestroyRef, computed, effect, - isSignal, Signal, signal, + isSignal, Signal, } from '@angular/core'; import { AGENT_CONFIG } from './agent.provider'; import { toSignal, toObservable } from '@angular/core/rxjs-interop'; @@ -162,49 +162,13 @@ export function agent< // Convert to Angular Signals (must happen in injection context) const value = toSignal(maybeThrottle(values$), { initialValue: init }); - // rawMessages is hand-rolled instead of `toSignal(maybeThrottle(messages$))` - // to avoid the leading/trailing throttle collapsing the optimistic-user - // injection into the same emission as the first AI partial. We bypass the - // throttle whenever the messages array grows in length (new message added) - // so user-visible message additions render in their own frame. Same-length - // updates (token-by-token AI streaming) still get throttled to ~60fps. - const rawMessagesSig = signal([]); - { - let lastLen = 0; - let throttleHandle: ReturnType | null = null; - let pending: BaseMessage[] | null = null; - const flushPending = () => { - throttleHandle = null; - if (pending) { - rawMessagesSig.set(pending); - pending = null; - } - }; - messages$ - .pipe(takeUntil(destroy$)) - .subscribe((m) => { - if (m.length !== lastLen) { - // Length changed (add or remove): emit synchronously, cancel pending. - lastLen = m.length; - if (throttleHandle !== null) { - clearTimeout(throttleHandle); - throttleHandle = null; - pending = null; - } - rawMessagesSig.set(m); - } else if (ms > 0) { - // Same-length update (token streaming): coalesce within the throttle window. - pending = m; - if (throttleHandle === null) { - throttleHandle = setTimeout(flushPending, ms); - } - } else { - // No throttle configured: emit immediately. - rawMessagesSig.set(m); - } - }); - } - const rawMessages = rawMessagesSig.asReadonly(); + // No throttle on messages$: we need every token emission to propagate to + // Angular so streaming markdown actually streams. The bridge already + // batches per-tuple at the SDK level; further throttling at the signal + // boundary collapses tokens together and breaks visible token-by-token + // rendering. Same-frame multiple emissions are coalesced by Angular's + // CD anyway. + const rawMessages = toSignal(messages$, { initialValue: [] as BaseMessage[] }); const statusSig = toSignal(status$, { initialValue: ResourceStatus.Idle }); const errorSig = toSignal(error$, { initialValue: undefined as unknown }); const hasValueSig = toSignal(hasValue$, { initialValue: false }); @@ -226,20 +190,14 @@ export function agent< // ── Runtime-neutral projections ─────────────────────────────────────────── - // Memoise BaseMessage → Message projections by raw-message identity. This - // keeps the projected `id` stable for the same logical message across - // recomputes (e.g. token-by-token streaming emits a fresh array but the - // BaseMessage reference is the same). Track-by-id in chat-message-list - // depends on this identity to avoid DOM teardown + animation restarts. - const messageProjections = new WeakMap(); - const projectMessage = (m: BaseMessage): Message => { - let cached = messageProjections.get(m); - if (cached) return cached; - cached = toMessage(m); - messageProjections.set(m, cached); - return cached; - }; - const messagesNeutral = computed(() => rawMessages().map(projectMessage)); + // Project BaseMessage → Message on every recompute. We deliberately do + // NOT cache: the LangGraph SDK mutates the same AIMessage instance in + // place during token streaming (appends content to the same object), so + // any identity-based cache returns stale projections and Angular's + // `@let content = messageContent(message)` short-circuits — DOM never + // updates per token. DOM stability is provided by `track message.id` + // in chat-message-list, not by Message identity. + const messagesNeutral = computed(() => rawMessages().map(toMessage)); const toolCallsNeutral = computed(() => rawToolCalls().map(toToolCall)); @@ -391,13 +349,44 @@ function toMessage(m: BaseMessage): Message { return { id: (m.id as string | undefined) ?? (raw['id'] as string | undefined) ?? randomId(), role, - content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content), + content: extractTextContent(m.content), toolCallId: raw['tool_call_id'] as string | undefined, name: raw['name'] as string | undefined, extra: raw, }; } +/** + * Extract user-visible text from a `BaseMessage.content` value. + * + * LangChain's `BaseMessage.content` is `string | MessageContentComplex[]`. + * Reasoning-capable models (OpenAI gpt-5/o-series, Anthropic) emit complex + * arrays of typed blocks: `{type: 'text', text}`, `{type: 'reasoning', ...}`, + * tool-use blocks, etc. We render only the visible text portions and skip + * anything else. JSON-stringifying the whole array (the previous behaviour) + * would dump raw `[{"type":"text",...}]` into the chat bubble. + */ +function extractTextContent(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + let out = ''; + for (const block of content) { + if (typeof block === 'string') { + out += block; + continue; + } + if (!isRecord(block)) continue; + const t = block['type']; + // Common text-bearing block shapes across providers. + if (t === 'text' || t === 'output_text' || t === undefined) { + const text = block['text']; + if (typeof text === 'string') out += text; + } + // Skip reasoning, tool_use, image, etc. — not chat-bubble content. + } + return out; +} + function toToolCall(tc: ToolCallWithResult): ToolCall { const stateMap: Record = { pending: 'pending', diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index 28c573f65..5c743e65c 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -389,8 +389,24 @@ export function createStreamManagerBridge { + if (i !== projected.length - 1) return true; + const t = normalizeMessageType( + typeof m._getType === 'function' ? m._getType() : (m as unknown as Record)['type'] as string | undefined, + ); + if (t !== 'ai') return true; + const text = extractText(m.content); + return text.length > 0; + }); // Preserve existing ids by content match (server echo / final-id swap). - const remapped = preserveIds(subjects.messages$.value, projected); + const remapped = preserveIds(subjects.messages$.value, filtered); // ALWAYS merge values-derived messages into existing rather // than replacing. LangGraph emits intermediate values events // during streaming where state.messages can lag behind what @@ -703,10 +719,52 @@ function normalizeMessages(event: StreamEvent): unknown[] | null { return null; } +/** + * Collapse adjacent AI messages where one's text is a prefix of the other. + * + * When complex-content streaming is in play, the same conceptual assistant + * message can land in two slots: the canonical AI from values-sync (id + * `resp_…` or run id) and the chunk-streamed AIMessageChunk from + * messages-tuple (id `lc_run--…`). Both slots fill in parallel; once both + * carry the full text we collapse them, keeping the older slot's id so + * track-by-id stays stable in the chat list. + */ +function collapseAdjacentAi(messages: BaseMessage[]): BaseMessage[] { + if (messages.length < 2) return messages; + const out: BaseMessage[] = []; + for (const msg of messages) { + const last = out[out.length - 1]; + if (!last) { out.push(msg); continue; } + const lastType = normalizeMessageType( + typeof last._getType === 'function' ? last._getType() : (last as unknown as Record)['type'] as string | undefined, + ); + const msgType = normalizeMessageType( + typeof msg._getType === 'function' ? msg._getType() : (msg as unknown as Record)['type'] as string | undefined, + ); + if (lastType === 'ai' && msgType === 'ai') { + const lastText = extractText(last.content); + const msgText = extractText(msg.content); + if (lastText.length === 0 + || msgText.length === 0 + || lastText === msgText + || lastText.startsWith(msgText) + || msgText.startsWith(lastText)) { + // Keep the longer content; preserve last (older) id and metadata. + const longerText = msgText.length >= lastText.length ? msgText : lastText; + out[out.length - 1] = { ...(last as object), content: longerText } as BaseMessage; + continue; + } + } + out.push(msg); + } + return out; +} + function mergeMessages(existing: BaseMessage[], incoming: BaseMessage[]): BaseMessage[] { const merged = [...existing]; for (const msg of incoming) { - const id = (msg as unknown as Record)['id']; + const rawIn = msg as unknown as Record; + const id = rawIn['id']; let idx = id ? merged.findIndex(m => (m as unknown as Record)['id'] === id) : -1; // Fallback: match by (role, content) when ids differ. This is the path // that fires when the server echoes back our optimistic human message @@ -717,18 +775,100 @@ function mergeMessages(existing: BaseMessage[], incoming: BaseMessage[]): BaseMe if (idx < 0) { idx = findContentMatch(merged, msg); } + // When an AIMessageChunk arrives without an id-match or content-prefix + // match, treat the trailing AI message as its accumulator. The + // OpenAI Responses API emits per-chunk events whose ids identify the + // *event*, not the message, so consecutive chunks land here. Without + // this we'd append every chunk as a separate bubble. + if (idx < 0) { + const inType = normalizeMessageType(rawIn['type'] as string | undefined); + if (inType === 'ai') { + for (let i = merged.length - 1; i >= 0; i--) { + const t = normalizeMessageType( + typeof (merged[i] as BaseMessage)._getType === 'function' + ? (merged[i] as BaseMessage)._getType() + : (merged[i] as unknown as Record)['type'] as string | undefined, + ); + if (t === 'ai') { idx = i; break; } + if (t === 'human' || t === 'tool' || t === 'system') break; + } + } + } if (idx >= 0) { - const existingId = (merged[idx] as unknown as Record)['id']; + const existing = merged[idx]; + const existingId = (existing as unknown as Record)['id']; // Keep the *existing* id so downstream track-by-id sees stable identity. - // The replacement carries the latest content + metadata. - merged[idx] = existingId - ? ({ ...(msg as object), id: existingId } as BaseMessage) - : msg; + // For complex-content streaming (OpenAI gpt-5/o-series, Anthropic) the + // SDK emits per-chunk *delta* arrays — not accumulated arrays — so a + // straight replacement collapses the rendered bubble to just the + // latest token. Accumulate text-bearing content across chunks here + // and hand a string to consumers; downstream code already handles + // string content uniformly. + const accumulatedContent = accumulateContent( + existing.content as unknown, + (msg as unknown as Record)['content'], + ); + const next = { ...(msg as object), content: accumulatedContent } as BaseMessage; + if (existingId) { + (next as unknown as Record)['id'] = existingId; + } + merged[idx] = next; } else { merged.push(msg); } } - return merged; + return collapseAdjacentAi(merged); +} + +/** + * Merge an incoming chunk's content into prior accumulated content for the + * same message id. + * + * - string + string → concat (delta append) + * - array + array → concat extracted text from existing + incoming blocks + * - array + string → use the string (server final-id swap) + * - empty existing → use incoming as-is + * + * We deliberately collapse complex content arrays to a string at this layer. + * The langgraph-sdk client does not accumulate complex-content arrays the + * way it accumulates strings, and per-chunk arrays carry only the latest + * delta. Concatenating extracted text gives consumers the same uniform + * string they get for non-reasoning models. + */ +function accumulateContent(existing: unknown, incoming: unknown): string { + const existingText = extractText(existing); + const incomingText = extractText(incoming); + + // Always return a string. We never want array content escaping the bridge: + // (a) downstream consumers expect string content, and (b) findContentMatch + // stringifies arrays, which would prevent the canonical-message id-swap + // dedupe from matching the streamed-chunk message after a partial chunk. + if (existingText.length === 0) return incomingText; + if (incomingText.length === 0) return existingText; + // Incoming is a strict-superset of accumulated (final-id swap with full content). + if (incomingText.startsWith(existingText)) return incomingText; + // Existing already a strict-superset — chunk arrived after the canonical + // message merged in via values-sync. Keep what we have. + if (existingText.startsWith(incomingText)) return existingText; + // Otherwise treat incoming as a delta and append. + return existingText + incomingText; +} + +function extractText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + let out = ''; + for (const block of content) { + if (typeof block === 'string') { out += block; continue; } + if (block == null || typeof block !== 'object') continue; + const rec = block as Record; + const t = rec['type']; + if (t === 'text' || t === 'output_text' || t === undefined) { + const text = rec['text']; + if (typeof text === 'string') out += text; + } + } + return out; } /** @@ -737,9 +877,9 @@ function mergeMessages(existing: BaseMessage[], incoming: BaseMessage[]): BaseMe * track-by-id stable across server echoes and final-id swaps. */ function preserveIds(existing: BaseMessage[], incoming: BaseMessage[]): BaseMessage[] { - if (existing.length === 0) return incoming; + if (existing.length === 0) return collapseAdjacentAi(incoming); const usedExisting = new Set(); - return incoming.map((msg, i) => { + const remapped = incoming.map((msg, i) => { const inRaw = msg as unknown as Record; const inId = inRaw['id']; // First try same-position match (the dominant case). @@ -756,11 +896,16 @@ function preserveIds(existing: BaseMessage[], incoming: BaseMessage[]): BaseMess if (!existingId || existingId === inId) return msg; return { ...(msg as object), id: existingId } as BaseMessage; }); + return collapseAdjacentAi(remapped); } function sameRoleAndContent(a: BaseMessage, b: BaseMessage): boolean { - const aType = typeof a._getType === 'function' ? a._getType() : (a as unknown as Record)['type']; - const bType = typeof b._getType === 'function' ? b._getType() : (b as unknown as Record)['type']; + const aType = normalizeMessageType( + typeof a._getType === 'function' ? a._getType() : (a as unknown as Record)['type'] as string | undefined, + ); + const bType = normalizeMessageType( + typeof b._getType === 'function' ? b._getType() : (b as unknown as Record)['type'] as string | undefined, + ); if (aType !== bType) return false; const aContent = typeof a.content === 'string' ? a.content : JSON.stringify(a.content); const bContent = typeof b.content === 'string' ? b.content : JSON.stringify(b.content); @@ -774,26 +919,56 @@ function sameRoleAndContent(a: BaseMessage, b: BaseMessage): boolean { function findContentMatch(merged: BaseMessage[], incoming: BaseMessage): number { const inRaw = incoming as unknown as Record; - const inType = typeof incoming._getType === 'function' ? incoming._getType() : (inRaw['type'] as string | undefined); + const inType = normalizeMessageType( + typeof incoming._getType === 'function' ? incoming._getType() : (inRaw['type'] as string | undefined), + ); const inContent = typeof incoming.content === 'string' ? incoming.content : JSON.stringify(incoming.content); // Only worth matching for human messages (where the optimistic→echo // mismatch happens) and for AI messages where content is a strict prefix // of the existing (token-streaming + final-id swap pattern). for (let i = merged.length - 1; i >= 0; i--) { const m = merged[i] as unknown as Record; - const mType = typeof (merged[i] as BaseMessage)._getType === 'function' - ? (merged[i] as BaseMessage)._getType() - : (m['type'] as string | undefined); + const mType = normalizeMessageType( + typeof (merged[i] as BaseMessage)._getType === 'function' + ? (merged[i] as BaseMessage)._getType() + : (m['type'] as string | undefined), + ); if (mType !== inType) continue; const mContent = typeof (merged[i] as BaseMessage).content === 'string' ? (merged[i] as BaseMessage).content as string : JSON.stringify((merged[i] as BaseMessage).content); if (inType === 'human' && mContent === inContent) return i; - if (inType === 'ai' && (mContent === inContent || (typeof mContent === 'string' && typeof inContent === 'string' && (inContent.startsWith(mContent) || mContent.startsWith(inContent))))) return i; + if (inType === 'ai') { + // Skip empty placeholders. We don't want a pre-existing empty AI + // (created by an early values-sync emission with `state.messages` + // including an unfilled assistant turn) to absorb the first chunk + // arriving via messages-tuple — that strands subsequent chunks in a + // separate slot whose content no longer prefix-matches the canonical. + const aSafe = typeof mContent === 'string' ? mContent : ''; + const bSafe = typeof inContent === 'string' ? inContent : ''; + if (aSafe.length === 0 || bSafe.length === 0) continue; + if (mContent === inContent || aSafe.startsWith(bSafe) || bSafe.startsWith(aSafe)) return i; + } } return -1; } +/** + * Normalize message type so AIMessage and AIMessageChunk compare equal. + * The LangGraph SDK emits type='AIMessageChunk' on the messages-tuple + * streaming path and type='ai' on the values-sync path for the same + * canonical assistant message — distinguishing them prevents the + * content-prefix dedupe from collapsing the duplicate bubbles. + */ +function normalizeMessageType(t: string | undefined): string | undefined { + if (!t) return t; + if (t === 'AIMessageChunk' || t === 'AIMessage' || t === 'assistant') return 'ai'; + if (t === 'HumanMessage' || t === 'HumanMessageChunk' || t === 'user') return 'human'; + if (t === 'ToolMessage') return 'tool'; + if (t === 'SystemMessage') return 'system'; + return t; +} + function toSubagentRefs( subagents: Map, ): Map {