diff --git a/docs/superpowers/plans/2026-06-11-ag-ui-demo-itinerary-client-tools.md b/docs/superpowers/plans/2026-06-11-ag-ui-demo-itinerary-client-tools.md new file mode 100644 index 000000000..516b45665 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-ag-ui-demo-itinerary-client-tools.md @@ -0,0 +1,295 @@ +# AG-UI Demo — Itinerary Client Tools + Suggestions 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:** The public AG-UI demo gains a frontend-owned trip-itinerary (signals + localStorage) that the user edits in a side panel and the agent edits via five client tools, plus seven welcome suggestion chips covering the demo's full capability surface. + +**Architecture:** All frontend state lives in a new `ItineraryStore`; the panel and the client-tool handlers are both thin consumers of it, so agent writes appear live. The backend binds the client catalog from `state["tools"]` via the published `threadplane-client-tools` middleware and ends the turn on pure client-tool calls. Spec: `docs/superpowers/specs/2026-06-11-ag-ui-demo-itinerary-client-tools-design.md`. + +**Tech Stack:** Angular 20 signals, `@threadplane/chat` `tools()/action/view/ask`, `zod/v4`, LangGraph + `threadplane-client-tools` (PyPI), Playwright + aimock replay e2e, Vitest. + +**Branch:** `claude/ag-ui-demo-client-tools` (exists; spec committed). Demo serve: angular `:4201`, backend uvicorn `:8000`. + +**Constraints:** Never run `npm install`/regenerate the root `package-lock.json`. `uv lock`/`uv export` inside `examples/ag-ui/python` only. Never reference "copilotkit" or "hashbrown". `pyenv: cannot rehash` shell noise is benign. + +--- + +### Task 1: `ItineraryStore` (TDD) + +**Files:** +- Create: `examples/ag-ui/angular/src/app/itinerary-store.ts` +- Test: `examples/ag-ui/angular/src/app/itinerary-store.spec.ts` + +- [ ] **Step 1: Write the failing tests** — cover: seed on empty storage; `add` appends with generated id + persists; `move` is case-insensitive on place and returns the stop (or `undefined` when absent); `clearDay` removes only that day; `remove(id)`; `reset()` restores the seed; hydration from a pre-populated `localStorage`. Use a fresh `localStorage.clear()` in `beforeEach`; instantiate via `TestBed.runInInjectionContext(() => new ItineraryStore())` only if injection is needed — prefer a plain class with no DI so tests are `new ItineraryStore()`. + +```ts +// itinerary-store.spec.ts (shape — write all 7 cases) +import { describe, it, expect, beforeEach } from 'vitest'; +import { ItineraryStore, ITINERARY_STORAGE_KEY } from './itinerary-store'; + +describe('ItineraryStore', () => { + beforeEach(() => localStorage.clear()); + + it('seeds the Paris trip when storage is empty', () => { + const s = new ItineraryStore(); + expect(s.stops().length).toBe(3); + expect(s.days()[0].day).toBe(1); + }); + + it('add appends a stop and persists it', () => { + const s = new ItineraryStore(); + s.add(2, 'Sainte-Chapelle', 'morning'); + expect(s.stops().some((x) => x.place === 'Sainte-Chapelle')).toBe(true); + expect(localStorage.getItem(ITINERARY_STORAGE_KEY)).toContain('Sainte-Chapelle'); + }); + + it('move matches place case-insensitively and returns the stop', () => { + const s = new ItineraryStore(); + const moved = s.move('louvre', 2); + expect(moved?.day).toBe(2); + expect(s.move('atlantis', 1)).toBeUndefined(); + }); + // …clearDay, remove, reset, hydrate cases +}); +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx nx test examples-ag-ui-angular -- itinerary-store` (file missing). +- [ ] **Step 3: Implement** + +```ts +// itinerary-store.ts +// SPDX-License-Identifier: MIT +import { computed, signal } from '@angular/core'; + +export interface ItineraryStop { id: string; day: number; place: string; note?: string; } +export const ITINERARY_STORAGE_KEY = 'ag-ui-demo:itinerary'; + +const SEED: ItineraryStop[] = [ + { id: 'seed-1', day: 1, place: 'Louvre', note: 'book tickets' }, + { id: 'seed-2', day: 1, place: 'Eiffel Tower' }, + { id: 'seed-3', day: 2, place: "Musée d'Orsay" }, +]; + +/** Frontend-owned demo state: the user edits it in the panel, the agent edits + * it through client tools. Both write the same signals, so either's changes + * render immediately. Persisted to localStorage so it survives reload. */ +export class ItineraryStore { + readonly stops = signal(this.hydrate()); + readonly days = computed(() => { + const byDay = new Map(); + for (const s of this.stops()) byDay.set(s.day, [...(byDay.get(s.day) ?? []), s]); + return [...byDay.entries()].sort(([a], [b]) => a - b) + .map(([day, stops]) => ({ day, stops })); + }); + + add(day: number, place: string, note?: string): ItineraryStop { + const stop: ItineraryStop = { id: crypto.randomUUID(), day, place, ...(note ? { note } : {}) }; + this.update([...this.stops(), stop]); + return stop; + } + move(place: string, toDay: number): ItineraryStop | undefined { + const target = this.stops().find((s) => s.place.toLowerCase() === place.toLowerCase()); + if (!target) return undefined; + const moved = { ...target, day: toDay }; + this.update(this.stops().map((s) => (s.id === target.id ? moved : s))); + return moved; + } + remove(id: string): void { this.update(this.stops().filter((s) => s.id !== id)); } + clearDay(day: number): number { + const removed = this.stops().filter((s) => s.day === day).length; + this.update(this.stops().filter((s) => s.day !== day)); + return removed; + } + reset(): void { this.update([...SEED]); } + + private update(next: ItineraryStop[]): void { + this.stops.set(next); + try { localStorage.setItem(ITINERARY_STORAGE_KEY, JSON.stringify(next)); } catch { /* private mode */ } + } + private hydrate(): ItineraryStop[] { + try { + const raw = localStorage.getItem(ITINERARY_STORAGE_KEY); + if (raw) return JSON.parse(raw) as ItineraryStop[]; + } catch { /* fall through to seed */ } + return [...SEED]; + } +} +``` + +- [ ] **Step 4: Run to verify PASS** — same command. +- [ ] **Step 5: Commit** — `feat(examples/ag-ui): ItineraryStore — frontend-owned demo state`. + +--- + +### Task 2: Itinerary panel + two-column layout + +**Files:** +- Create: `examples/ag-ui/angular/src/app/itinerary-panel.component.ts` +- Modify: `examples/ag-ui/angular/src/app/app.ts` (provide one `ItineraryStore` instance), `app.html` (panel beside chat), demo stylesheet (`examples/ag-ui/angular/src/styles.css` or the component's styles — match the existing styling approach in the app). + +- [ ] **Step 1: Component** — standalone, OnPush, `input.required()` named `store`; renders `@for (g of store().days(); …)` with day headings, stop rows (place + note + ✕ remove), an add row (day number input, place text input, Add button → `store().add(...)`), and a "Reset demo data" button → `store().reset()`. Keep styles inline in the component (the demo's components do this); follow the existing `.ag-ui-demo__*` BEM naming. +- [ ] **Step 2: Layout** — in `app.html`, wrap the chat in a flex row: panel (fixed ~300px, `aria-label="Trip itinerary"`) + chat (flex-1). Stack via media query under 900px. In `app.ts`: `protected readonly itinerary = new ItineraryStore();` +- [ ] **Step 3: Verify** — `npx nx build examples-ag-ui-angular` green; `npx nx test examples-ag-ui-angular` green. +- [ ] **Step 4: Commit** — `feat(examples/ag-ui): itinerary panel beside the chat`. + +--- + +### Task 3: Client tools + welcome chips + +**Files:** +- Create: `examples/ag-ui/angular/src/app/client-tools.ts`, `clear-day-confirm.component.ts`, `day-card.component.ts` +- Modify: `app.ts` (build tools from the store; chips array + `send()`), `app.html` (`[clientTools]` on ``, chips projection) + +- [ ] **Step 1: Components.** + - `DayCardComponent` (view): inputs `day: number`, `places: string[]`; small card listing the day's places. Selector `app-day-card`. + - `ClearDayConfirmComponent` (ask): input `day: number`; injects the host via `injectRenderHost()` from `@threadplane/render`; needs the live stop count + the actual clear — inject nothing: receive only `day`, and emit the DECISION; the store mutation happens in the tool layer? **No** — ask components emit the result value; the handler-side cannot intercept. So the component must perform the clear. Give it access to the store via a static-injected app-level provider: provide the `ItineraryStore` instance through Angular DI — in `app.config.ts` add `{ provide: ItineraryStore, useClass: ItineraryStore }` and have BOTH `app.ts` and the component `inject(ItineraryStore)`. (Adjust Task 2's `app.ts` to `inject(ItineraryStore)` instead of `new`.) Component: "Clear all {{count}} stops on day {{day}}?" with **Clear** → `store.clearDay(day)`; `host.result({ cleared: true, day, removed })` and **Cancel** → `host.result({ cleared: false, day })`. Selector `app-clear-day-confirm`. +- [ ] **Step 2: Tools registry.** + +```ts +// client-tools.ts +// SPDX-License-Identifier: MIT +import { inject } from '@angular/core'; +import { tools, action, view, ask, type ClientToolRegistry } from '@threadplane/chat'; +import { z } from 'zod/v4'; +import { ItineraryStore } from './itinerary-store'; +import { DayCardComponent } from './day-card.component'; +import { ClearDayConfirmComponent } from './clear-day-confirm.component'; + +/** Client tools over the frontend-owned itinerary. Call inside an injection + * context (e.g. a field initializer in App). The descriptions are the ONLY + * steering the model gets — no system-prompt coaching (by design). */ +export function itineraryClientTools(): ClientToolRegistry { + const store = inject(ItineraryStore); + return tools({ + get_itinerary: action( + "Read the user's trip itinerary: every planned stop grouped by day (with ids).", + z.object({}), + async () => ({ days: store.days() }), + ), + add_stop: action( + 'Add a stop to a day of the trip itinerary.', + z.object({ day: z.number().int().min(1), place: z.string(), note: z.string().optional() }), + async ({ day, place, note }) => ({ added: store.add(day, place, note) }), + ), + move_stop: action( + 'Move an existing stop (matched by place name) to another day.', + z.object({ place: z.string(), toDay: z.number().int().min(1) }), + async ({ place, toDay }) => { + const moved = store.move(place, toDay); + return moved ? { moved } : { error: `No stop named "${place}" — call get_itinerary to see what exists.` }; + }, + ), + clear_day: ask( + 'Ask the user to confirm clearing every stop on a day, then clear it if they accept.', + z.object({ day: z.number().int().min(1) }), + ClearDayConfirmComponent, + ), + day_card: view( + "Show the user a recap card for one itinerary day after you've changed it.", + z.object({ day: z.number().int().min(1), places: z.array(z.string()) }), + DayCardComponent, + ), + }); +} +``` + +- [ ] **Step 3: Chips + wiring.** In `app.ts`: + +```ts +protected readonly clientTools = itineraryClientTools(); +protected readonly suggestions = [ + { label: 'Docs & citations', value: 'What do the docs say about streaming?' }, + { label: 'Generative UI', value: 'Build me a revenue dashboard' }, + { label: 'Human approval', value: 'Issue me a $50 refund' }, + { label: 'Read my itinerary', value: "What's on my itinerary?" }, + { label: 'Agent edits the page', value: 'Add the Louvre to day 2 of my trip' }, + { label: 'Consent-gated clear', value: 'Clear my day 2 plans' }, + { label: 'Research subagent', value: 'Research AG-UI and give me the highlights' }, +]; +protected send(value: string): void { void this.agent.submit({ message: value }); } +``` + +In `app.html`: `` and inside it project: + +```html +
+ @for (s of suggestions; track s.value) { + + } +
+``` + +(Import `ChatWelcomeSuggestionComponent` in `app.ts` imports. Verify the slot renders — `` projects `[chatWelcomeSuggestions]` through to `chat-welcome`; mirror `cockpit/langgraph/streaming/angular/src/app/streaming.component.ts` if projection needs the exact wrapper.) +- [ ] **Step 4: Verify** — build + unit tests green. `git commit -m "feat(examples/ag-ui): itinerary client tools + capability suggestion chips"`. + +--- + +### Task 4: Backend — bind the client catalog + +**Files:** +- Modify: `examples/ag-ui/python/src/graph.py`, `examples/ag-ui/python/pyproject.toml` +- Regenerate: `examples/ag-ui/python/uv.lock`, `requirements.txt` + +- [ ] **Step 1: Dep** — `pyproject.toml` dependencies += `"threadplane-client-tools>=0.0.1"`; from `examples/ag-ui/python`: `uv lock && uv export --no-hashes -o requirements.txt && uv sync`. +- [ ] **Step 2: State channel** — `class State(TypedDict)` gains `tools: Optional[list]` (ag-ui-langgraph merges `RunAgentInput.tools` into `state["tools"]`; the channel must exist to be retained). +- [ ] **Step 3: Bind** — in `generate`, replace `llm = ChatOpenAI(**kwargs).bind_tools([search_documents, request_approval, research, gen_ui_tool])` with: + +```python +from threadplane_client_tools import bind_client_tools, client_tool_names + +llm = bind_client_tools( + ChatOpenAI(**kwargs), + [search_documents, request_approval, research, gen_ui_tool], + state, +) +``` + +- [ ] **Step 4: Routing** — `should_continue` must end the turn on pure client-tool calls (the browser executes them and re-runs): + +```python +def should_continue(state: State) -> Literal["tools", "attach_citations"]: + """Route to tools when any SERVER tool_call is present. A turn whose + calls are all client tools must END so the browser can execute them + and re-run with the ToolMessage; attach_citations is the terminal + post-process (a no-op without search results).""" + last = state["messages"][-1] + if not (isinstance(last, AIMessage) and last.tool_calls): + return "attach_citations" + client = client_tool_names(state) + if all(tc["name"] in client for tc in last.tool_calls): + return "attach_citations" + return "tools" +``` + +Mixed server+client calls keep today's `tools` route — ToolNode emits an error ToolMessage for the unknown client tool; accepted demo edge (note it in a comment). +- [ ] **Step 5: Verify** — `OPENAI_API_KEY=x uv run python -c "from src.graph import graph; print('ok')"`; existing demo e2e must still pass later (Task 5 runs the suite). +- [ ] **Step 6: Commit** — `feat(examples/ag-ui): bind the client tool catalog (threadplane-client-tools)`. + +--- + +### Task 5: e2e — itinerary read + ask chains + +**Files:** +- Create: `examples/ag-ui/angular/e2e/fixtures/itinerary.json`, `examples/ag-ui/angular/e2e/itinerary-client-tools.spec.ts` + +- [ ] **Step 1: Fixtures** — read `examples/ag-ui/angular/e2e/fixtures/hi.json` and `interrupt-approval.json` FIRST and mirror the local schema exactly. Entries needed (match-block shapes as in the cockpit client-tools fixtures): for "What's on my itinerary?" → `toolCalls: [{ name: 'get_itinerary', arguments: {} }]` + a `hasToolResult: true` continuation ("You have 3 stops planned…"); for "Clear my day 2 plans" → `toolCalls: [{ name: 'clear_day', arguments: { day: 2 } }]` + continuation ("Done — day 2 is cleared."). +- [ ] **Step 2: Spec** — use the local helpers (`openDemo`, `sendPromptAndWait`/`waitForFinalAssistant`, `messageInput`, `sendButton` from `./test-helpers`): + - read test: send "What's on my itinerary?" → expect the continuation text visible (proves the two-run client round-trip over this app). + - ask test: send "Clear my day 2 plans" → `app-clear-day-confirm` visible with "day 2" → panel still shows the day-2 stop → click **Clear** → expect day-2 group empties in the panel AND the continuation renders. + - panel test: `aria-label="Trip itinerary"` panel visible on load with the 3 seeded stops; clear `localStorage` in `beforeEach` via `page.addInitScript`. +- [ ] **Step 3: Run** — `npx nx e2e examples-ag-ui-angular` — new tests AND all existing specs green (the suite boots uvicorn :8000 + angular :4201 under aimock). +- [ ] **Step 4: Commit** — `test(examples/ag-ui): e2e for itinerary client tools (read + ask chains)`. + +--- + +### Task 6: Live-LLM smoke + PR (orchestrator-run, not a subagent) + +- [ ] Serve locally with the real key (backend `uv run uvicorn src.server:app --port 8000` with `OPENAI_API_KEY` from the repo root `.env`; `npx nx serve examples-ag-ui-angular --port 4201`). +- [ ] Drive ALL seven chips in Chrome; additionally exercise clear-day **Cancel** (state untouched) and a `move_stop` prompt ("Move the Louvre to day 1"). Confirm panel updates on every agent write and continuations stream after each client round-trip. +- [ ] Open PR → CI → merge on green → demo backend + "ag-ui demo → Vercel" redeploy. + +## Self-review notes + +- Spec coverage: store/panel (T1–2), five tools + chips (T3), backend (T4), e2e (T5), live smoke + deploy (T6) — all spec sections mapped. +- Type consistency: `ItineraryStore.days()` shape `{day, stops}[]` is what `get_itinerary` returns and the panel renders; `clear_day` result `{cleared, day, removed?}`; `ClientToolRegistry` import exists in `@threadplane/chat` public API. +- Deliberate deviation captured in-plan: `ItineraryStore` becomes a DI provider (Task 3 Step 1) because the ask component must reach the store; Task 2's `new ItineraryStore()` is adjusted accordingly. diff --git a/docs/superpowers/specs/2026-06-11-ag-ui-demo-itinerary-client-tools-design.md b/docs/superpowers/specs/2026-06-11-ag-ui-demo-itinerary-client-tools-design.md new file mode 100644 index 000000000..5d06af9dd --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-ag-ui-demo-itinerary-client-tools-design.md @@ -0,0 +1,85 @@ +# AG-UI Demo — Itinerary Client Tools + Capability Suggestions — Design + +**Date:** 2026-06-11 +**Status:** Draft for review +**Scope:** Give the public AG-UI demo (`examples/ag-ui`, deployed by the "ag-ui demo → Vercel" job) two things it lacks: (1) **client tools** demonstrated against *frontend-owned application state* — a visible trip-itinerary panel the user and the agent both read and write — and (2) **welcome suggestion chips** covering every capability the demo now showcases. Today the demo's welcome screen has no chips, and the demo has no client-tools wiring. + +## Goal + +Make the demo show the full current capability surface in one screen, with the client-tools story told the strongest way: the agent reaches into **live application state the user can see and touch**. The user edits the itinerary panel directly; the agent edits the *same* state through client tools; the panel updates either way. This is the deliberate inverse of the json-render example (backend-owned state → frontend): here the state is frontend-owned and the agent reaches in. + +## Non-goals + +- No changes to the chat lib, render lib, or adapters — the demo consumes published APIs (`tools`/`action`/`view`/`ask`, `[clientTools]`). +- No browser-context tools (`get_local_context` etc.) — considered and dropped; one coherent state story beats two competing ones. They remain a cockpit-example concern. +- No system-prompt coaching for the client tools. The catalog descriptions must carry the behavior — the demo dogfoods the feature's core promise. + +## 1. Frontend-owned state: `ItineraryStore` + +New `examples/ag-ui/angular/src/app/itinerary-store.ts` — an Angular-signals store: + +```ts +interface ItineraryStop { id: string; day: number; place: string; note?: string; } +``` + +- `stops = signal(seed)` with computed day-grouping. +- Mutations: `add(day, place, note?)`, `move(place, toDay)` (case-insensitive place match), `remove(id)`, `clearDay(day)`, `reset()`. +- **Persistence:** `localStorage` key `ag-ui-demo:itinerary`; hydrate on init, write-through on mutation. +- **Seed** (so the demo reads instantly): Paris trip — Day 1: Louvre ("book tickets"), Eiffel Tower; Day 2: Musée d'Orsay. + +## 2. Itinerary panel UI + +New `itinerary-panel.component.ts`: a compact panel beside the chat (`.ag-ui-demo` becomes a two-column layout on desktop, stacked on mobile). Day-grouped list; per-stop remove button; small "add stop" input (day select + place text); a "Reset demo data" affordance. Pure consumer of `ItineraryStore` — the agent's writes appear live because both write the same signals. + +## 3. Client tools (new `client-tools.ts`, passed via ``) + +| Tool | Kind | Schema (zod/v4) | Behavior | +|---|---|---|---| +| `get_itinerary` | action | `{}` | returns `{ days: [{ day, stops: [{ id, place, note? }] }] }` | +| `add_stop` | action | `{ day: number, place: string, note?: string }` | `store.add(...)`; returns the added stop | +| `move_stop` | action | `{ place: string, toDay: number }` | `store.move(...)`; returns moved stop or a not-found error result | +| `clear_day` | **ask** | `{ day: number }` | `ClearDayConfirmComponent`: "Clear all N stops on day {day}?" Confirm → `store.clearDay(day)` + emit `{ cleared: true, day }`; Cancel → `{ cleared: false }` | +| `day_card` | view | `{ day: number, places: string[] }` | `DayCardComponent` recap card in the transcript; model-filled, auto-acked | + +Notes: +- `move_stop` matches by place name so casual prompts work; `get_itinerary` exposes ids so the model can disambiguate duplicates. +- `clear_day` is the human-gated destructive write — Cancel must leave state untouched and return a result the model can react to. +- `day_card` has no dedicated chip; the model reaches for it after edits at its own discretion (descriptions guide it). + +## 4. Welcome suggestion chips + +Project `chat-welcome-suggestion` chips (label/value, same pattern as the cockpit examples) into the `[chatWelcomeSuggestions]` slot in `app.html`, with a `send(value)` handler in `app.ts` (`agent.submit({ message })`). Seven chips, two rows: + +**Row 1 — backend capabilities** +1. "What do the docs say about streaming?" → search_documents + citations +2. "Build me a revenue dashboard" → gen-UI surface (a2ui / json-render per mode) +3. "Issue me a $50 refund" → request_approval → interrupt panel + +**Row 2 — client tools + subagent** +4. "What's on my itinerary?" → `get_itinerary` +5. "Add the Louvre to day 2 of my trip" → `add_stop` (panel visibly changes) +6. "Clear my day 2 plans" → `clear_day` ask-confirm +7. "Research AG-UI and give me the highlights" → research subagent + +## 5. Backend changes (`examples/ag-ui/python`) + +- `State` gains a `tools` channel (list) — `ag-ui-langgraph` merges `RunAgentInput.tools` into `state["tools"]`; the channel must exist for it to be retained. +- `generate` binds the client catalog: `bind_client_tools(llm, [search_documents, request_approval, research, gen_ui_tool], state)` (from the published `threadplane-client-tools>=0.0.1`). +- Routing: when the model's calls are **pure client-tool calls**, the turn must END (the browser executes and re-runs with the ToolMessage). Server tool calls keep routing to `ToolNode` exactly as today. Use the middleware's `has_server_tool_call` / `client_tool_names` helpers inside the demo's existing `should_continue`; a client-tool-only turn routes to the turn-ending path (skipping `attach_citations` is acceptable on those turns). +- `pyproject.toml` + regenerated `uv.lock`/`requirements.txt` add `threadplane-client-tools>=0.0.1`. + +## 6. Verification + +- **Live-LLM local smoke (standing gate):** serve the demo locally with a real key; drive all seven chips in a real browser; confirm the panel updates on agent writes, the clear-day confirm gates the write (both Confirm and Cancel paths), and continuations stream after each client-tool round-trip. +- **aimock e2e:** extend `examples/ag-ui/angular/e2e` with a client-tools spec — fixtures for the read ("What's on my itinerary?" → `get_itinerary` call + continuation) and the ask chain ("Clear my day 2 plans" → `clear_day` call; click Confirm; assert the panel's day-2 group empties and the continuation renders). Existing demo specs stay green. +- **Unit:** `ItineraryStore` (mutations, persistence round-trip, case-insensitive move, clearDay). + +## 7. Deploy + +Normal main-merge path: the demo backend redeploys via its existing job; the frontend via "ag-ui demo → Vercel". No new infra. The backend dep resolves from PyPI (already published). + +## Risks / notes + +- The `move_stop` name-match can miss on typos; the tool returns a structured not-found result so the model can recover by calling `get_itinerary` — acceptable for a demo. +- `localStorage` seeds can drift from new deploys; the panel's "Reset demo data" affordance is the escape hatch. +- Chip prompts are live-LLM prompts (not fixture-bound); wording was chosen to route reliably to the intended tool, validated during the live smoke. diff --git a/examples/ag-ui/angular/e2e/fixtures/itinerary.json b/examples/ag-ui/angular/e2e/fixtures/itinerary.json new file mode 100644 index 000000000..186b41ebf --- /dev/null +++ b/examples/ag-ui/angular/e2e/fixtures/itinerary.json @@ -0,0 +1,22 @@ +{ + "fixtures": [ + { + "match": { "userMessage": "What's on my itinerary?", "hasToolResult": true }, + "response": { + "content": "You have 3 stops planned: the Louvre and the Eiffel Tower on day 1, and the Musée d'Orsay on day 2." + } + }, + { + "match": { "userMessage": "What's on my itinerary?" }, + "response": { "toolCalls": [ { "name": "get_itinerary", "arguments": {} } ] } + }, + { + "match": { "userMessage": "Clear my day 2 plans", "hasToolResult": true }, + "response": { "content": "Done — day 2 is cleared." } + }, + { + "match": { "userMessage": "Clear my day 2 plans" }, + "response": { "toolCalls": [ { "name": "clear_day", "arguments": { "day": 2 } } ] } + } + ] +} diff --git a/examples/ag-ui/angular/e2e/global-setup.ts b/examples/ag-ui/angular/e2e/global-setup.ts index f9f5207bd..27d066d62 100644 --- a/examples/ag-ui/angular/e2e/global-setup.ts +++ b/examples/ag-ui/angular/e2e/global-setup.ts @@ -48,6 +48,13 @@ export default async function globalSetup(): Promise { ...process.env, OPENAI_BASE_URL: aimock.baseUrl, OPENAI_API_KEY: 'test-not-used', + // Run the backend in clone-and-run (unauthenticated) mode so the suite + // is hermetic. The dev proxy forwards /agent without an x-internal-token + // header, so if the developer's root .env defines AG_UI_INTERNAL_TOKEN + // (used for the Railway deploys) nx leaks it into process.env and the + // require_internal_token middleware 401s every run. Blanking it here + // matches what the proxy + transport expect. + AG_UI_INTERNAL_TOKEN: '', }, stdio: 'pipe', }, diff --git a/examples/ag-ui/angular/e2e/itinerary-client-tools.spec.ts b/examples/ag-ui/angular/e2e/itinerary-client-tools.spec.ts new file mode 100644 index 000000000..b1c44a3fd --- /dev/null +++ b/examples/ag-ui/angular/e2e/itinerary-client-tools.spec.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { attachBrowserHygiene, messageInput, openDemo, sendButton } from './test-helpers'; + +// Exercises the frontend-declared / frontend-executed client tools over the +// AG-UI transport against THIS app. The catalog ships to the backend, the model +// emits a tool call, the browser executes it against the shared ItineraryStore, +// the ToolMessage re-runs the graph, and the continuation streams back. Each +// test starts from the store seed (Day 1: Louvre + Eiffel Tower, Day 2: Musée +// d'Orsay) by clearing the persisted key before the page hydrates. +const STORAGE_KEY = 'ag-ui-demo:itinerary'; + +test.beforeEach(async ({ page }) => { + // Runs on every navigation, before app bootstrap — so ItineraryStore + // hydrates from SEED rather than a stale localStorage payload. openDemo's + // own localStorage.clear() runs after the first paint; this guarantees the + // key is already absent the moment the store reads it. + await page.addInitScript((key) => localStorage.removeItem(key), STORAGE_KEY); +}); + +test('panel renders the seeded itinerary', async ({ page }) => { + await openDemo(page); + + const panel = page.getByRole('region', { name: 'Trip itinerary' }); + await expect(panel).toBeVisible(); + await expect(panel).toContainText('Louvre'); + await expect(panel).toContainText('Eiffel Tower'); + await expect(panel).toContainText("Musée d'Orsay"); +}); + +test('read round-trip: get_itinerary executes in the browser and the run continues', async ({ + page, +}) => { + await openDemo(page); + const hygiene = attachBrowserHygiene(page); + + await messageInput(page).fill("What's on my itinerary?"); + await sendButton(page).click(); + + // The first run returns only a tool call; the browser executes get_itinerary + // against the store, the ToolMessage re-runs the graph, and the continuation + // streams the recap. Wait on that final content rather than the first settle. + await expect(page.getByText('You have 3 stops planned')).toBeVisible({ timeout: 30_000 }); + + expect(hygiene.consoleErrors).toEqual([]); +}); + +test('ask chain: clear_day confirm mutates the panel and resumes the run', async ({ page }) => { + await openDemo(page); + + await messageInput(page).fill('Clear my day 2 plans'); + await sendButton(page).click(); + + // The clear_day ask renders its confirm component; the run is paused on the + // emitted tool result until the user decides. + const confirm = page.locator('app-clear-day-confirm'); + await expect(confirm).toBeVisible({ timeout: 30_000 }); + await expect(confirm).toContainText('day 2'); + + // Nothing has mutated yet — the panel still shows day 2's stop. + const panel = page.getByRole('region', { name: 'Trip itinerary' }); + await expect(panel).toContainText("Musée d'Orsay"); + + // Confirming writes the store (panel updates live) and emits the tool result + // that resumes the run, whose continuation streams the closing line. + await confirm.getByRole('button', { name: 'Clear' }).click(); + + await expect(panel).not.toContainText("Musée d'Orsay"); + await expect(page.getByText('Done — day 2 is cleared.')).toBeVisible({ timeout: 30_000 }); +}); diff --git a/examples/ag-ui/angular/src/app/app.config.ts b/examples/ag-ui/angular/src/app/app.config.ts index 28d0278fe..9010b8e67 100644 --- a/examples/ag-ui/angular/src/app/app.config.ts +++ b/examples/ag-ui/angular/src/app/app.config.ts @@ -8,6 +8,7 @@ import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; import { provideChat } from '@threadplane/chat'; import { provideAgent } from '@threadplane/ag-ui'; import { environment } from '../environments/environment'; +import { ItineraryStore } from './itinerary-store'; export const appConfig: ApplicationConfig = { providers: [ @@ -16,5 +17,9 @@ export const appConfig: ApplicationConfig = { provideThreadplaneTelemetry(environment.telemetry), provideAgent({ url: environment.agentUrl }), provideChat({ license: environment.license }), + // The frontend-owned itinerary is a single shared instance: the panel, + // the App component, and the client-tool ask component all inject it, so + // user edits and agent writes hit the same signals and render live. + ItineraryStore, ], }; diff --git a/examples/ag-ui/angular/src/app/app.html b/examples/ag-ui/angular/src/app/app.html index 50ed6d632..b21d9684f 100644 --- a/examples/ag-ui/angular/src/app/app.html +++ b/examples/ag-ui/angular/src/app/app.html @@ -3,10 +3,27 @@

AG-UI Chat

The Threadplane chat UI over the AG-UI transport.

- @if (agent.interrupt && agent.interrupt()) { -
- +
+ +
+ @if (agent.interrupt && agent.interrupt()) { +
+ +
+ } + +
+ @for (s of suggestions; track s.value) { + + } +
+
- } - +
diff --git a/examples/ag-ui/angular/src/app/app.ts b/examples/ag-ui/angular/src/app/app.ts index f48ab8a77..9d6127d07 100644 --- a/examples/ag-ui/angular/src/app/app.ts +++ b/examples/ag-ui/angular/src/app/app.ts @@ -4,15 +4,23 @@ import { injectAgent } from '@threadplane/ag-ui'; import { ChatComponent, ChatInterruptPanelComponent, + ChatWelcomeSuggestionComponent, a2uiBasicCatalog, type InterruptAction, } from '@threadplane/chat'; +import { ItineraryPanelComponent } from './itinerary-panel.component'; +import { itineraryClientTools } from './client-tools'; @Component({ selector: 'app-root', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ChatComponent, ChatInterruptPanelComponent], + imports: [ + ChatComponent, + ChatInterruptPanelComponent, + ChatWelcomeSuggestionComponent, + ItineraryPanelComponent, + ], templateUrl: './app.html', }) export class App { @@ -22,6 +30,29 @@ export class App { // render_a2ui_surface tool call shows only as a tool chip (issue #616). protected readonly catalog = a2uiBasicCatalog(); + // Built in an injection context (field initializer) so itineraryClientTools() + // can inject the shared ItineraryStore. These declare what the agent can do + // to the page; the browser executes each call against the same store the + // panel renders. + protected readonly clientTools = itineraryClientTools(); + + // Welcome chips spanning the demo's full capability surface — docs/citations, + // generative UI, human approval, the five itinerary client tools, and the + // research subagent. Selecting one submits its prompt verbatim. + protected readonly suggestions = [ + { label: 'Docs & citations', value: 'What do the docs say about streaming?' }, + { label: 'Generative UI', value: 'Build me a revenue dashboard' }, + { label: 'Human approval', value: 'Issue me a $50 refund' }, + { label: 'Read my itinerary', value: "What's on my itinerary?" }, + { label: 'Agent edits the page', value: 'Add the Louvre to day 2 of my trip' }, + { label: 'Consent-gated clear', value: 'Clear my day 2 plans' }, + { label: 'Research subagent', value: 'Research AG-UI and give me the highlights' }, + ]; + + protected send(value: string): void { + void this.agent.submit({ message: value }); + } + /** * Resolve a human-in-the-loop interrupt (request_approval). The * chat-interrupt-panel emits a four-action vocabulary; map each to a resume diff --git a/examples/ag-ui/angular/src/app/clear-day-confirm.component.ts b/examples/ag-ui/angular/src/app/clear-day-confirm.component.ts new file mode 100644 index 000000000..293f27340 --- /dev/null +++ b/examples/ag-ui/angular/src/app/clear-day-confirm.component.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { injectRenderHost } from '@threadplane/render'; +import { ItineraryStore } from './itinerary-store'; + +/** + * The interactive component for the `clear_day` client tool (an `ask`). The + * model fills `day`; the user confirms or cancels. Because an ask emits the + * tool result and the handler layer cannot intercept it, the mutation happens + * HERE: Clear writes the shared `ItineraryStore` (so the panel updates live) + * and then announces the outcome via `injectRenderHost().result(...)`, which + * becomes the tool result that resumes the run. Cancel never touches the store. + */ +@Component({ + selector: 'app-clear-day-confirm', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Clear all {{ count() }} stops on day {{ day() }}?

+
+ + +
+
+ `, + styles: [ + ` + .cdc { + border: 1px solid var(--ngaf-chat-separator, #e5e7eb); + border-radius: 12px; + padding: 16px; + max-width: 360px; + } + .cdc__summary { + margin: 0 0 12px; + } + .cdc__actions { + display: flex; + gap: 8px; + } + .cdc__btn { + padding: 6px 14px; + border-radius: 8px; + border: 1px solid var(--ngaf-chat-separator, #e5e7eb); + background: transparent; + color: inherit; + cursor: pointer; + } + .cdc__btn--primary { + background: var(--ngaf-chat-accent, #2563eb); + color: #fff; + border-color: transparent; + } + `, + ], +}) +export class ClearDayConfirmComponent { + readonly day = input.required(); + private readonly store = inject(ItineraryStore); + private readonly host = injectRenderHost(); + + protected readonly count = computed( + () => this.store.stops().filter((s) => s.day === this.day()).length, + ); + + protected clear(): void { + const day = this.day(); + const removed = this.store.clearDay(day); + this.host.result({ cleared: true, day, removed }); + } + + protected cancel(): void { + this.host.result({ cleared: false, day: this.day() }); + } +} diff --git a/examples/ag-ui/angular/src/app/client-tools.ts b/examples/ag-ui/angular/src/app/client-tools.ts new file mode 100644 index 000000000..2546a4d7b --- /dev/null +++ b/examples/ag-ui/angular/src/app/client-tools.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +import { inject } from '@angular/core'; +import { tools, action, view, ask, type ClientToolRegistry } from '@threadplane/chat'; +import { z } from 'zod/v4'; +import { ItineraryStore } from './itinerary-store'; +import { DayCardComponent } from './day-card.component'; +import { ClearDayConfirmComponent } from './clear-day-confirm.component'; + +/** Client tools over the frontend-owned itinerary. Call inside an injection + * context (e.g. a field initializer in App). The descriptions are the ONLY + * steering the model gets — no system-prompt coaching (by design). */ +export function itineraryClientTools(): ClientToolRegistry { + const store = inject(ItineraryStore); + return tools({ + get_itinerary: action( + "Read the user's trip itinerary: every planned stop grouped by day (with ids).", + z.object({}), + async () => ({ days: store.days() }), + ), + add_stop: action( + 'Add a stop to a day of the trip itinerary.', + z.object({ day: z.number().int().min(1), place: z.string(), note: z.string().optional() }), + async ({ day, place, note }) => ({ added: store.add(day, place, note) }), + ), + move_stop: action( + 'Move an existing stop (matched by place name) to another day.', + z.object({ place: z.string(), toDay: z.number().int().min(1) }), + async ({ place, toDay }) => { + const moved = store.move(place, toDay); + return moved + ? { moved } + : { error: `No stop named "${place}" — call get_itinerary to see what exists.` }; + }, + ), + clear_day: ask( + 'Ask the user to confirm clearing every stop on a day, then clear it if they accept.', + z.object({ day: z.number().int().min(1) }), + ClearDayConfirmComponent, + ), + day_card: view( + "Show the user a recap card for one itinerary day after you've changed it.", + z.object({ day: z.number().int().min(1), places: z.array(z.string()) }), + DayCardComponent, + ), + }); +} diff --git a/examples/ag-ui/angular/src/app/day-card.component.ts b/examples/ag-ui/angular/src/app/day-card.component.ts new file mode 100644 index 000000000..d38d1ef32 --- /dev/null +++ b/examples/ag-ui/angular/src/app/day-card.component.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +/** + * A frontend-owned view rendered for the `day_card` client tool. The model + * fills `day` and `places`; this card recaps one itinerary day after an edit. + */ +@Component({ + selector: 'app-day-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
Day {{ day() }}
+
    + @for (p of places(); track p) { +
  • {{ p }}
  • + } @empty { +
  • No stops
  • + } +
+
+ `, + styles: [ + ` + .dc { + border: 1px solid var(--ngaf-chat-separator, #e5e7eb); + border-radius: 12px; + padding: 16px; + max-width: 280px; + } + .dc__head { + font-weight: 600; + margin-bottom: 8px; + } + .dc__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + .dc__item { + opacity: 0.9; + } + .dc__item--empty { + opacity: 0.5; + } + `, + ], +}) +export class DayCardComponent { + readonly day = input.required(); + readonly places = input([]); +} diff --git a/examples/ag-ui/angular/src/app/itinerary-panel.component.ts b/examples/ag-ui/angular/src/app/itinerary-panel.component.ts new file mode 100644 index 000000000..7886810f5 --- /dev/null +++ b/examples/ag-ui/angular/src/app/itinerary-panel.component.ts @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { ItineraryStore } from './itinerary-store'; + +/** + * The user-facing side of the frontend-owned itinerary: a panel that reads and + * writes the shared `ItineraryStore`. The agent's client tools write the same + * store, so an agent edit re-renders these rows immediately (no round-trip). + */ +@Component({ + selector: 'app-itinerary-panel', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'itin', role: 'region', 'aria-label': 'Trip itinerary' }, + template: ` +
+

Trip itinerary

+ +
+ + @for (g of store.days(); track g.day) { +
+

Day {{ g.day }}

+
    + @for (s of g.stops; track s.id) { +
  • + + {{ s.place }} + @if (s.note) { — {{ s.note }} } + + +
  • + } +
+
+ } @empty { +

No stops planned yet.

+ } + +
+ + + +
+ `, + styles: [ + ` + :host { + display: block; + padding: 16px; + font-size: 0.9rem; + } + .itin__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; + } + .itin__title { + margin: 0; + font-size: 1rem; + } + .itin__reset { + font-size: 0.75rem; + background: transparent; + border: 1px solid var(--tp-border, #e5e7eb); + border-radius: 6px; + padding: 4px 8px; + color: inherit; + cursor: pointer; + opacity: 0.8; + } + .itin__reset:hover { + opacity: 1; + } + .itin__day { + margin-bottom: 12px; + } + .itin__day-title { + margin: 0 0 4px; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.6; + } + .itin__stops { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + .itin__stop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border: 1px solid var(--tp-border, #e5e7eb); + border-radius: 8px; + } + .itin__place { + min-width: 0; + } + .itin__note { + opacity: 0.6; + } + .itin__remove { + flex: none; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + opacity: 0.5; + font-size: 0.8rem; + line-height: 1; + padding: 2px 4px; + } + .itin__remove:hover { + opacity: 1; + } + .itin__empty { + opacity: 0.6; + margin: 8px 0; + } + .itin__add { + display: flex; + gap: 6px; + margin-top: 12px; + } + .itin__add-day { + width: 56px; + } + .itin__add-place { + flex: 1 1 auto; + min-width: 0; + } + .itin__add input { + padding: 6px 8px; + border: 1px solid var(--tp-border, #e5e7eb); + border-radius: 6px; + background: transparent; + color: inherit; + } + .itin__add-btn { + flex: none; + padding: 6px 12px; + border: 1px solid transparent; + border-radius: 6px; + background: var(--a2ui-primary, #2563eb); + color: var(--a2ui-on-primary, #fff); + cursor: pointer; + } + `, + ], +}) +export class ItineraryPanelComponent { + protected readonly store = inject(ItineraryStore); + protected readonly newDay = signal(1); + protected readonly newPlace = signal(''); + + protected addStop(event: Event): void { + event.preventDefault(); + const place = this.newPlace().trim(); + if (!place) return; + this.store.add(this.newDay(), place); + this.newPlace.set(''); + } +} diff --git a/examples/ag-ui/angular/src/app/itinerary-store.spec.ts b/examples/ag-ui/angular/src/app/itinerary-store.spec.ts new file mode 100644 index 000000000..fa7934390 --- /dev/null +++ b/examples/ag-ui/angular/src/app/itinerary-store.spec.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { ItineraryStore, ITINERARY_STORAGE_KEY } from './itinerary-store'; + +describe('ItineraryStore', () => { + beforeEach(() => localStorage.clear()); + + it('seeds the Paris trip when storage is empty', () => { + const s = new ItineraryStore(); + expect(s.stops().length).toBe(3); + expect(s.days()[0].day).toBe(1); + }); + + it('add appends a stop and persists it', () => { + const s = new ItineraryStore(); + s.add(2, 'Sainte-Chapelle', 'morning'); + expect(s.stops().some((x) => x.place === 'Sainte-Chapelle')).toBe(true); + expect(localStorage.getItem(ITINERARY_STORAGE_KEY)).toContain('Sainte-Chapelle'); + }); + + it('move matches place case-insensitively and returns the stop', () => { + const s = new ItineraryStore(); + const moved = s.move('louvre', 2); + expect(moved?.day).toBe(2); + expect(s.move('atlantis', 1)).toBeUndefined(); + }); + + it('clearDay removes only stops for that day', () => { + const s = new ItineraryStore(); + // seed has 2 stops on day 1, 1 stop on day 2 + const removed = s.clearDay(1); + expect(removed).toBe(2); + expect(s.stops().every((x) => x.day !== 1)).toBe(true); + // day 2 stop still exists + expect(s.stops().some((x) => x.day === 2)).toBe(true); + }); + + it('remove deletes the stop with the given id', () => { + const s = new ItineraryStore(); + const first = s.stops()[0]; + s.remove(first.id); + expect(s.stops().find((x) => x.id === first.id)).toBeUndefined(); + expect(s.stops().length).toBe(2); + }); + + it('reset restores the seed stops', () => { + const s = new ItineraryStore(); + s.add(3, 'Versailles'); + s.reset(); + expect(s.stops().length).toBe(3); + expect(s.stops().every((x) => x.id.startsWith('seed-'))).toBe(true); + }); + + it('hydrates from pre-populated localStorage', () => { + const stored = [{ id: 'test-1', day: 5, place: 'Arc de Triomphe', note: 'sunset' }]; + localStorage.setItem(ITINERARY_STORAGE_KEY, JSON.stringify(stored)); + const s = new ItineraryStore(); + expect(s.stops().length).toBe(1); + expect(s.stops()[0].place).toBe('Arc de Triomphe'); + expect(s.stops()[0].day).toBe(5); + }); +}); diff --git a/examples/ag-ui/angular/src/app/itinerary-store.ts b/examples/ag-ui/angular/src/app/itinerary-store.ts new file mode 100644 index 000000000..41989d290 --- /dev/null +++ b/examples/ag-ui/angular/src/app/itinerary-store.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +import { computed, signal } from '@angular/core'; + +export interface ItineraryStop { + id: string; + day: number; + place: string; + note?: string; +} + +export const ITINERARY_STORAGE_KEY = 'ag-ui-demo:itinerary'; + +const SEED: ItineraryStop[] = [ + { id: 'seed-1', day: 1, place: 'Louvre', note: 'book tickets' }, + { id: 'seed-2', day: 1, place: 'Eiffel Tower' }, + { id: 'seed-3', day: 2, place: "Musée d'Orsay" }, +]; + +/** Frontend-owned demo state: the user edits it in the panel, the agent edits + * it through client tools. Both write the same signals, so either's changes + * render immediately. Persisted to localStorage so it survives reload. */ +export class ItineraryStore { + readonly stops = signal(this.hydrate()); + readonly days = computed(() => { + const byDay = new Map(); + for (const s of this.stops()) byDay.set(s.day, [...(byDay.get(s.day) ?? []), s]); + return [...byDay.entries()] + .sort(([a], [b]) => a - b) + .map(([day, stops]) => ({ day, stops })); + }); + + add(day: number, place: string, note?: string): ItineraryStop { + const stop: ItineraryStop = { + id: crypto.randomUUID(), + day, + place, + ...(note ? { note } : {}), + }; + this.update([...this.stops(), stop]); + return stop; + } + + move(place: string, toDay: number): ItineraryStop | undefined { + const target = this.stops().find((s) => s.place.toLowerCase() === place.toLowerCase()); + if (!target) return undefined; + const moved = { ...target, day: toDay }; + this.update(this.stops().map((s) => (s.id === target.id ? moved : s))); + return moved; + } + + remove(id: string): void { + this.update(this.stops().filter((s) => s.id !== id)); + } + + clearDay(day: number): number { + const removed = this.stops().filter((s) => s.day === day).length; + this.update(this.stops().filter((s) => s.day !== day)); + return removed; + } + + reset(): void { + this.update([...SEED]); + } + + private update(next: ItineraryStop[]): void { + this.stops.set(next); + try { + localStorage.setItem(ITINERARY_STORAGE_KEY, JSON.stringify(next)); + } catch { + /* private mode */ + } + } + + private hydrate(): ItineraryStop[] { + try { + const raw = localStorage.getItem(ITINERARY_STORAGE_KEY); + if (raw) return JSON.parse(raw) as ItineraryStop[]; + } catch { + /* fall through to seed */ + } + return [...SEED]; + } +} diff --git a/examples/ag-ui/angular/src/styles.css b/examples/ag-ui/angular/src/styles.css index e9d37df2b..d0b29ceb8 100644 --- a/examples/ag-ui/angular/src/styles.css +++ b/examples/ag-ui/angular/src/styles.css @@ -99,3 +99,25 @@ html[data-theme='material-light'] { .ag-ui-demo__header p { margin: 2px 0 0; font-size: 0.85rem; opacity: 0.7; } .ag-ui-demo__interrupt { padding: 12px 16px; border-bottom: 1px solid var(--tp-border, #e5e7eb); } .ag-ui-demo__chat { flex: 1 1 auto; min-height: 0; } + +/* Two-column body: itinerary panel beside the chat. */ +.ag-ui-demo__body { flex: 1 1 auto; min-height: 0; display: flex; } +.ag-ui-demo__panel { + flex: 0 0 300px; + min-height: 0; + overflow-y: auto; + border-right: 1px solid var(--tp-border, #e5e7eb); +} +.ag-ui-demo__main { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; } + +/* Stack to a single column on narrow viewports; the panel goes full-width + * above the chat. */ +@media (max-width: 900px) { + .ag-ui-demo__body { flex-direction: column; } + .ag-ui-demo__panel { + flex: 0 0 auto; + max-height: 40dvh; + border-right: none; + border-bottom: 1px solid var(--tp-border, #e5e7eb); + } +} diff --git a/examples/ag-ui/angular/tsconfig.spec.json b/examples/ag-ui/angular/tsconfig.spec.json new file mode 100644 index 000000000..d8d944394 --- /dev/null +++ b/examples/ag-ui/angular/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.d.ts", "src/test-setup.ts"] +} diff --git a/examples/ag-ui/python/pyproject.toml b/examples/ag-ui/python/pyproject.toml index 7cbd62c08..21367db55 100644 --- a/examples/ag-ui/python/pyproject.toml +++ b/examples/ag-ui/python/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "fastapi>=0.115", "uvicorn>=0.30", "python-dotenv>=1.0", + "threadplane-client-tools>=0.0.1", ] [tool.uv] diff --git a/examples/ag-ui/python/requirements.txt b/examples/ag-ui/python/requirements.txt index f36244806..787aa9dd1 100644 --- a/examples/ag-ui/python/requirements.txt +++ b/examples/ag-ui/python/requirements.txt @@ -69,6 +69,7 @@ langchain-core==1.4.1 # langgraph-checkpoint # langgraph-prebuilt # langgraph-sdk + # threadplane-client-tools langchain-openai==1.2.2 # via examples-ag-ui-python langchain-protocol==0.0.16 @@ -80,6 +81,7 @@ langgraph==1.2.4 # ag-ui-langgraph # examples-ag-ui-python # langchain + # threadplane-client-tools langgraph-checkpoint==4.1.1 # via # langgraph @@ -141,6 +143,8 @@ starlette==1.2.1 # via fastapi tenacity==9.1.4 # via langchain-core +threadplane-client-tools==0.0.1 + # via examples-ag-ui-python tiktoken==0.13.0 # via langchain-openai tqdm==4.68.1 diff --git a/examples/ag-ui/python/src/graph.py b/examples/ag-ui/python/src/graph.py index 48e0c4c6d..17db912fb 100644 --- a/examples/ag-ui/python/src/graph.py +++ b/examples/ag-ui/python/src/graph.py @@ -42,6 +42,8 @@ from langgraph_sdk import get_client from langsmith import traceable +from threadplane.client_tools import bind_client_tools, client_tool_names + from src.streaming.a2ui_partial_handler import A2uiPartialHandler from src.streaming.envelope_tool import render_a2ui_surface from src.streaming.envelope_normalizer import normalize_envelope_args @@ -367,6 +369,10 @@ class State(TypedDict): model: Optional[str] reasoning_effort: Optional[str] gen_ui_mode: Optional[str] + # ag-ui-langgraph merges RunAgentInput.tools into state["tools"]; the + # channel must exist here so the graph retains the client catalog across + # the generate → should_continue → attach_citations path. + tools: Optional[list] async def generate(state: State, config: RunnableConfig) -> dict: @@ -402,8 +408,14 @@ async def generate(state: State, config: RunnableConfig) -> dict: # libs/chat/src/lib/a2ui/envelope-normalizer.ts) to canonicalize the # four observed argument shapes (envelopes / envelope / positional / # flat). The spike showed 80-93% canonical even without strict. - llm = ChatOpenAI(**kwargs).bind_tools( + # + # bind_client_tools appends the client catalog stubs (from state["tools"], + # populated by ag-ui-langgraph from RunAgentInput.tools) to the server + # tool list so the model can call any frontend-declared tool by name. + llm = bind_client_tools( + ChatOpenAI(**kwargs), [search_documents, request_approval, research, gen_ui_tool], + state, ) # Append A2UI v1 schema to system prompt when in a2ui mode, so the parent # LLM knows how to construct the envelopes directly. @@ -427,13 +439,21 @@ async def generate(state: State, config: RunnableConfig) -> dict: def should_continue(state: State) -> Literal["tools", "attach_citations"]: - """Conditional edge from generate: route to tools node when any - tool_call is present (GenUI tools, search, approval, research), - otherwise route to attach_citations terminal post-process.""" + """Route to tools when any SERVER tool_call is present. A turn whose + calls are all client tools must END so the browser can execute them + and re-run with the ToolMessage; attach_citations is the terminal + post-process (a no-op without search results). + + Mixed server+client calls keep the 'tools' route — ToolNode emits an + error ToolMessage for the unknown client tool name; accepted demo edge. + """ last = state["messages"][-1] - if isinstance(last, AIMessage) and last.tool_calls: - return "tools" - return "attach_citations" + if not (isinstance(last, AIMessage) and last.tool_calls): + return "attach_citations" + client = client_tool_names(state) + if all(tc["name"] in client for tc in last.tool_calls): + return "attach_citations" + return "tools" def after_tools(state: State) -> Literal["emit_generated_surface", "generate"]: diff --git a/examples/ag-ui/python/uv.lock b/examples/ag-ui/python/uv.lock index 166d36c94..fbdc8ea1f 100644 --- a/examples/ag-ui/python/uv.lock +++ b/examples/ag-ui/python/uv.lock @@ -193,6 +193,7 @@ dependencies = [ { name = "langchain-openai" }, { name = "langgraph" }, { name = "python-dotenv" }, + { name = "threadplane-client-tools" }, { name = "uvicorn" }, ] @@ -209,6 +210,7 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.3" }, { name = "python-dotenv", specifier = ">=1.0" }, + { name = "threadplane-client-tools", specifier = ">=0.0.1" }, { name = "uvicorn", specifier = ">=0.30" }, ] @@ -980,6 +982,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "threadplane-client-tools" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/6d/142e2686892859dd9fae080a08e0173f4adad0e25566de8ce3bcf569e844/threadplane_client_tools-0.0.1.tar.gz", hash = "sha256:9fbc7aab0167145f56f5caddb667ad56df9bd847b681d80243085d2c6f1a8ac3", size = 95499, upload-time = "2026-06-10T00:29:44.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/b7/6ca34dd8d642477e8558f1f9d2616ebe0b8189c44807af7f5e369867b1dc/threadplane_client_tools-0.0.1-py3-none-any.whl", hash = "sha256:0a898f4bd89101305fe0f9fd3e254902f0ddaadd41ab40a25bd4980c499bf3b8", size = 4288, upload-time = "2026-06-10T00:29:43.748Z" }, +] + [[package]] name = "tiktoken" version = "0.13.0"