From 32169ea183c1f6a907cb85bd91576ff81f24b775 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 12:37:53 -0700 Subject: [PATCH 1/9] docs: spec LLM-generated labels (thread titles + action labels) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes in one design pass: - Thread titles via inline per-cap node + LangGraph SDK metadata write (Pattern D from the design discussion — fully inline, no shared helper, matches per-cap pedagogical purpose). - Action message labels derived from the authored Button's child Text at emit time, killing the hardcoded KNOWN_LABELS map in libs/chat/src/lib/a2ui/action-label.ts (PR #464). Both backed by deep cross-library research (Open Canvas, Vercel, assistant-ui, CopilotKit, ChatGPT/Claude reference UX) showing universal consensus: titles are LLM-generated post-first-turn from thread metadata; action labels come from the authored UI element, never a centralized map in the rendering primitive. Scope: c-a2ui first (proves the pattern), document for other caps. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-llm-generated-labels-design.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md diff --git a/docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md b/docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md new file mode 100644 index 00000000..dea66f74 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md @@ -0,0 +1,322 @@ +# LLM-Generated Labels — Design + +**Date:** 2026-05-19 +**Status:** Spec — pending implementation plan + +## Goal + +Stop hardcoding user-facing labels in chat primitives and stop pretending non-LLM heuristics (regex/slice of the first user message) are "labels." Two concrete fixes, one design pass: + +1. **Thread titles** — replace the missing/sliced fallback with a small LLM call (cheap model, fires after first turn, writes to LangGraph thread metadata) so the sidenav shows meaningful titles like *"Flight LAX to JFK"* instead of UUIDs or 50-char prompt slices. + +2. **Action message labels** — remove the `KNOWN_LABELS` map added in PR #464 (which embedded app-specific action names like `bookingSubmit → "Search flights"` inside the chat library). Instead, derive the bubble label from the **already-LLM-authored source element** (the Button's child Text component). The protocol carries the machine name; the rendering layer humanizes from authored UI, not from a hardcoded map. + +## Research basis + +Research subagent surveyed LangChain/LangGraph, Vercel AI SDK, assistant-ui, CopilotKit/AG-UI, and the ChatGPT/Claude reference UX. Cross-cutting consensus: + +- **Thread title generation**: triggered after first AI turn, runs out-of-band, uses a cheap dedicated model (Haiku-class / gpt-4o-mini), persisted in thread metadata. Slicing the first user message is universally treated as a fallback only. +- **Action labels**: the tool/component author supplies the human label at definition time. The protocol carries the machine name; the rendering layer humanizes. **Nobody hardcodes a per-app `KNOWN_LABELS` map in the chat primitive itself.** AG-UI explicitly states "consumers should translate `getBookedFlights` → 'Determining booked flights…' in the UI" — i.e. the rendering layer humanizes from authored metadata, not from a centralized string table in the lib. + +Today's codebase violates both. This spec fixes them. + +## Decisions + +| # | Decision | Choice | +|---|---|---| +| 1 | Per-cap independence is non-negotiable | Each chat capability stays self-contained: own `pyproject.toml`, own `src/`, no cross-cap python imports. Per-cap migration (PR #413) established this; we don't undo it. | +| 2 | Where the title-generation logic lives | **Fully inline in each cap's `graph.py`** — node definition + LLM call mechanics + SDK write, all visible in one file. Cost: ~30 lines duplicated per cap. Benefit: a developer studying the cap reads ONE file and sees everything the agent does. Matches the cap's pedagogical purpose. | +| 3 | When the title node fires | Conditional edge after the cap's terminal node (typically `respond`). Skips when thread metadata already has `thread_title` (idempotent). Triggered on first AI turn only. | +| 4 | Title-generation model | `gpt-5-mini` per-cap default. Each cap can override (e.g. c-a2ui can use gpt-5 if it wants schema-aware titles). | +| 5 | Title prompt | Short and prescriptive: *"In 3-5 words, summarize what the user is asking about. Output ONLY the title — no quotes, no period, no prefix."* Matches Vercel's pattern. | +| 6 | Title persistence | LangGraph SDK `client.threads.update(thread_id, metadata={"thread_title": title})`. First-class metadata field on `Thread`. | +| 7 | Title failures | Swallowed (title is a UX nicety, never a blocker). The cap's main flow returns normally even if title gen errors. | +| 8 | Action label source | The **source Button's child Text** — already authored by the LLM as part of the surface spec. `libs/chat/src/lib/a2ui/build-action-message.ts` looks up `sourceComponentId` in the surface, walks to the Button's `child` (Text id), reads its `literalString`, and stamps it on the outgoing `A2uiActionMessage.action.label`. | +| 9 | Action label fallback | When the source component isn't a Button-with-Text-child (e.g. CheckBox toggle, MultipleChoice selection, Modal action), fall back to camelCase humanization of `action.name`. Preserves PR #464's behavior for those cases. | +| 10 | Kill `KNOWN_LABELS` | The hardcoded map in `libs/chat/src/lib/a2ui/action-label.ts` is removed. Chat-lib stops knowing about specific app actions. | +| 11 | Frontend → backend wire compatibility | `action.label` is a new optional field on `A2uiActionMessage.action`. Backends that ignore it continue to work; backends that want to use it (e.g. for routing or logging) can read it. | +| 12 | Scope of this PR | **c-a2ui first** (the cap that exercises both surfaces most thoroughly). Pattern documented for other caps to adopt. No bulk migration. | + +## Architecture overview + +### Half 1 — Thread titles (Pattern D: inline node, per-cap) + +``` +START → agent ↔ tools → respond → should_title ┬─→ generate_title → END + └────────────────── END (already titled, or no thread_id) +``` + +`generate_title` is a normal LangGraph node in the cap's graph builder. It: +1. Reads `config.configurable.thread_id` +2. Calls SDK `client.threads.get(thread_id)`; bails if `metadata.thread_title` exists +3. Walks `state["messages"]` for the first human message +4. Invokes a cheap LLM with the title prompt +5. Writes via `client.threads.update` +6. Returns `{}` (no state change) + +`should_title` is a conditional edge function on `respond` that returns `"generate_title"` only on first turn, `END` otherwise. (Could also be unconditional — generate_title's own idempotency check covers re-fires. Simpler: unconditional edge, idempotency in the node.) + +### Half 2 — Action labels (derive from authored UI) + +``` +[Surface render time] +LLM authors Button → child Text component with literalString = "Search flights" + +[User clicks button] +build-action-message.ts + ├─ resolves sourceComponentId → Button component in the surface + ├─ reads Button.child → Text id + ├─ reads Text.text.literalString → "Search flights" + └─ stamps `action.label = "Search flights"` on the outgoing message + +[Rendering the user bubble in the transcript] +chat.component.ts → humanContent(message) + ├─ a2uiActionLabel(content) parses the action message + ├─ returns `action.label` if present (← the new path) + └─ else humanizes camelCase action.name +``` + +No KNOWN_LABELS table. The Button author (the LLM) is the source of truth for what to call the action. + +## Files modified + +### Python (per-cap, fully inline) + +- `cockpit/chat/a2ui/python/src/graph.py` + - Add `generate_title` node (~25 lines: helper-free LLM call + SDK write) + - Add unconditional edge `respond → generate_title → END` + - No new imports outside what's already there + `langgraph_sdk.get_client` + +### TypeScript (chat-lib, centralized) + +- `libs/a2ui/src/lib/types.ts` + - Add optional `label?: string` to `A2uiActionMessage['action']` +- `libs/chat/src/lib/a2ui/build-action-message.ts` + - Accept the surface (already passed); look up the source Button → Text child; stamp `action.label` when found +- `libs/chat/src/lib/a2ui/action-label.ts` + - Prefer `action.label` when present + - **Remove `KNOWN_LABELS`** — leave only the camelCase humanizer as fallback + - Keep the public `a2uiActionLabel(content: string): string | null` signature unchanged + +### Frontend wiring (already supported) + +- `libs/chat/src/lib/primitives/chat-thread-list/...` — already reads `Thread.title`. No change. +- LangGraph SDK adapter — sources `Thread.title` from `thread.metadata.thread_title`. Need to verify this mapping happens already; if not, one-line patch in the langgraph adapter. + +## Implementation detail — the c-a2ui `generate_title` node (fully inline) + +```python +# In cockpit/chat/a2ui/python/src/graph.py — added near other nodes + +from langgraph_sdk import get_client + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata so the + sidenav shows something meaningful instead of a UUID slice. + + Idempotent — skips when metadata.thread_title already exists. Errors + are swallowed (title is a UX nicety, never a blocker). Runs after + `respond` so the user-visible turn is never blocked by title gen. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + try: + client = get_client(url="http://localhost:2024") # cockpit dev default + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("thread_title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + # Skip action-message JSON (those flow as human-role too) + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model="gpt-5-mini", temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"thread_title": title}) + except Exception as err: + _logger.warning("Thread title generation failed: %s", err) + return {} +``` + +Wired in the builder: + +```python +_builder.add_node("generate_title", generate_title) +_builder.add_edge("respond", "generate_title") +_builder.add_edge("generate_title", END) +# (remove the prior `_builder.add_edge("respond", END)`) +``` + +## Implementation detail — action label derivation + +### `libs/a2ui/src/lib/types.ts` + +```typescript +export interface A2uiActionMessage { + version: 'v1'; + action: { + name: string; + surfaceId: string; + sourceComponentId: string; + timestamp: string; + context: Record; + /** Optional human-friendly label for the action — typically derived + * from the source component's authored text (e.g. a Button's child + * Text literalString). Set by `buildA2uiActionMessage` when the + * source is a Button-with-Text-child; left undefined otherwise. + * Used by the chat-lib's transcript renderer to label the user + * bubble; backends may ignore. */ + label?: string; + }; + metadata?: { a2uiClientDataModel: A2uiClientDataModel }; +} +``` + +### `libs/chat/src/lib/a2ui/build-action-message.ts` + +```typescript +export function buildA2uiActionMessage( + params: Record, + surface: A2uiSurface, +): A2uiActionMessage { + const rawContext = (params['context'] as Record) ?? {}; + const wrappedContext: Record = {}; + for (const [k, v] of Object.entries(rawContext)) { + wrappedContext[k] = toDynamicValue(v); + } + + const sourceComponentId = params['sourceComponentId'] as string; + + const message: A2uiActionMessage = { + version: 'v1', + action: { + name: params['name'] as string, + surfaceId: surface.surfaceId, + sourceComponentId, + timestamp: new Date().toISOString(), + context: wrappedContext, + }, + }; + + // NEW: derive label from the source component when it's a Button with + // a Text child. The Text was authored by the LLM as part of the surface + // spec; reuse it as the action's display label. + const label = deriveActionLabel(surface, sourceComponentId); + if (label) message.action.label = label; + + if (surface.sendDataModel) { + message.metadata = { + a2uiClientDataModel: { + version: 'v1', + surfaces: { [surface.surfaceId]: surface.dataModel }, + }, + }; + } + return message; +} + +function deriveActionLabel(surface: A2uiSurface, sourceId: string): string | null { + const source = surface.components.get(sourceId); + if (!source) return null; + const buttonProps = (source.component as { Button?: { child?: string } }).Button; + if (!buttonProps?.child) return null; + const labelText = surface.components.get(buttonProps.child); + if (!labelText) return null; + const textProps = (labelText.component as { Text?: { text?: { literalString?: string } } }).Text; + return textProps?.text?.literalString ?? null; +} +``` + +### `libs/chat/src/lib/a2ui/action-label.ts` + +```typescript +// KNOWN_LABELS map is REMOVED entirely. + +export function a2uiActionLabel(content: string): string | null { + // ... parsing identical to today ... + + // NEW: prefer the authored label that was stamped at emit time. + if (typeof action['label'] === 'string' && action['label'].length > 0) { + return action['label']; + } + + // FALLBACK: humanize camelCase (unchanged from PR #464). + return humanizeCamelCase(name); +} +``` + +## Testing + +### Unit tests (chat-lib) + +- `build-action-message.spec.ts`: + - Button-with-Text-child source → `action.label` populated + - Source with no Button → `action.label` undefined + - Source with Button but no child Text id → `action.label` undefined + - Surface that doesn't have the sourceComponentId at all → `action.label` undefined (graceful) +- `action-label.spec.ts`: + - Action with `label` → returns that label verbatim + - Action without `label` → falls back to camelCase humanizer + - Verify NO KNOWN_LABELS lookup happens (use a name like `bookingSubmit` that PR #464's map would have hit → now returns `"Booking submit"`, not `"Search flights"`) + +### Integration / real-LLM smoke (c-a2ui) + +- 3-turn programmatic smoke against per-cap on :5511: + 1. First turn: `'I want to fly LAX to JFK'` → after `respond` completes, eventually `thread.metadata.thread_title` exists and is non-empty (~3-5 words) + 2. Second turn (same thread): `'Filter to cancelled flights'` → title is NOT overwritten (idempotency) + 3. Verify the action message emitted by the form's Search button carries `action.label = "Search flights"` (the LLM-authored Button text) + +### Chrome MCP smoke + +- Open c-a2ui at `:4511` +- Send first prompt via chip → wait for response → check sidenav: thread shows generated title (not UUID) +- Submit form → user bubble shows `"Search flights"` derived from the authored Button text (not from the removed KNOWN_LABELS map) + +## Risks and mitigations + +- **Title-gen blocks a CI test that asserts thread metadata is empty.** Mitigation: the node is unconditional but idempotent; tests can pass a thread_id whose metadata they don't check, or skip the metadata assertion. +- **`get_client(url=...)` hardcodes the dev URL.** Mitigation: read from `LANGGRAPH_API_URL` env with fallback to `http://localhost:2024`. Inline this in the node body (matches the inline pattern — no hidden config). +- **LLM occasionally emits a title in quotes or with a period.** Mitigation: strip both at the call site (`.strip().strip('"').strip("'")[:80]`). Done in the snippet above. +- **`get_client()` creates a new client per invocation.** Acceptable for the cockpit demo's throughput. If profiling shows it as hot, switch to a module-level cached instance. +- **Action label derivation only handles Button source today.** Acceptable: Buttons account for ~all form-submit and card-action interactions in our surfaces. CheckBox/MultipleChoice/Slider don't have a single text label that maps 1:1 to the action; humanized camelCase is a fine fallback for them. +- **Breaking change for any external consumer relying on `KNOWN_LABELS` behavior** (e.g. `bookingSubmit` → `"Search flights"` was hardcoded; now becomes `"Booking submit"` unless the Button is authored with that text). Mitigation: any LLM authoring a booking form already writes the Button's text as `"Search flights"`; the derivation reads that. So the visible bubble is unchanged in practice. PR #464 itself was only ~24 hours old at spec time. +- **`label` field on the wire**: if any agent ignores unknown fields strictly, this could break it. LangChain's BaseMessage shape passes through unknown JSON properties fine. Mitigation: documented in the field's docstring. + +## Out-of-scope follow-ups + +- Roll out to other cockpit caps (c-generative-ui, c-tool-calls, c-subagents, c-interrupts, ...). Each follows the same inline pattern. Done one at a time, separate PRs. +- Title regeneration policy when thread is renamed by the user or after N turns. Today: write-once-on-first-turn. Future: opt-in re-summarization triggered by a heuristic. +- Pluggable title model selection per-cap (today: hardcoded to `gpt-5-mini` in the node body). Each cap can edit its own constant; no centralization needed yet. +- Action label derivation for non-Button sources (CheckBox label, Slider label, MultipleChoice options). Each requires a different DOM walk; ship one when a real use case appears. +- Consider extracting the title call mechanics to a narrow utility if duplication across caps becomes painful (target: 3+ caps doing it inline before extracting). Not now — we want to see real cap usage patterns first. +- Cleanup: when c-a2ui is the proof point, the other caps can adopt the pattern in batches. No central scheduler / migration plan needed today. + +## Self-review + +**Spec coverage:** both fixes (titles + labels) are addressed. Pattern D (inline) is locked in for titles. KNOWN_LABELS removal + derivation-from-authored-UI is locked in for labels. + +**Placeholder scan:** all code snippets are complete and runnable; no TBDs. Models, prompts, paths, and signatures are concrete. + +**Type consistency:** +- `A2uiActionMessage.action.label?: string` — new optional field. `a2uiActionLabel` returns `string | null` (unchanged signature). `deriveActionLabel` returns `string | null`. Consistent. +- `generate_title` is an async LangGraph node taking `(state: MessagesState, config)`, returning `dict`. Matches LangGraph's expected node signature. +- `_builder.add_edge("respond", "generate_title")` — both are node names registered earlier in the file. Consistent. + +**Anti-pattern check:** zero hardcoded label tables. Zero topology magic. Zero cross-cap python imports. Each cap stays self-contained. From 05552c556b140aa6a45c2efe071687fad202cf8e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 12:48:17 -0700 Subject: [PATCH 2/9] docs: plan LLM-generated labels implementation 10-task plan covering: - libs/a2ui type extension (A2uiActionMessage.action.label?: string) - libs/chat/a2ui/build-action-message.ts label derivation + 4 new spec cases - libs/chat/a2ui/action-label.ts: drop KNOWN_LABELS, prefer authored label, fall back to camelCase humanizer; 11-case new spec - cockpit/chat/a2ui/python/src/graph.py: inline generate_title node (Pattern D from spec), wired between terminal nodes and END Implementer = Tasks 1-9 (code, tests, smoke). Orchestrator = Task 10 (push, PR, CI watch, merge). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-19-llm-generated-labels.md | 922 ++++++++++++++++++ 1 file changed, 922 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-llm-generated-labels.md diff --git a/docs/superpowers/plans/2026-05-19-llm-generated-labels.md b/docs/superpowers/plans/2026-05-19-llm-generated-labels.md new file mode 100644 index 00000000..87fd9194 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-llm-generated-labels.md @@ -0,0 +1,922 @@ +# LLM-Generated Labels 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:** Replace today's hardcoded `KNOWN_LABELS` map in `libs/chat` (from PR #464) with action-label derivation from the LLM-authored Button's child Text. Add inline LLM-driven thread-title generation to c-a2ui's per-cap graph (Pattern D from the design spec — fully inline node, no shared helper, no topology magic). + +**Architecture:** Two independent halves of one PR: +1. **chat-lib (TypeScript)** — `A2uiActionMessage.action.label?: string`; `buildA2uiActionMessage` derives + stamps it from the source Button's Text child; `a2uiActionLabel` prefers it; `KNOWN_LABELS` deleted. +2. **c-a2ui graph (Python)** — new inline `generate_title` node added to `cockpit/chat/a2ui/python/src/graph.py`; reads `config.configurable.thread_id`, idempotency-checks via SDK, calls gpt-5-mini, writes `metadata.thread_title`. Wired between `respond` and `END`. + +**Tech Stack:** TypeScript (libs/a2ui, libs/chat); Python 3.12 + langchain-openai + langgraph + langgraph-sdk (cockpit/chat/a2ui/python). No new dependencies. + +--- + +## Pre-flight (READ FIRST) + +**Branch:** spec is on `claude/labels-design-spec` (commit `32169ea1`). Implementation branches off the spec branch: + +```bash +git fetch origin +git checkout -b claude/llm-generated-labels claude/labels-design-spec +``` + +**Shared-checkout caveat:** this repo's working tree gets switched by parallel agents. Every code-modifying task begins with `git branch --show-current` check; STOP if you're not on `claude/llm-generated-labels`. + +**Hard rules:** +- One commit per code-modifying task (Tasks 1-7). +- Never `git add -A` or `git add .` — stage specific paths only. +- Verification snippets must produce the expected output before committing. +- Never push, open PR, or `--amend` from inside the implementer subagent — Task 10 (orchestrator) handles that. + +--- + +## File structure + +| File | Status | Purpose | +|---|---|---| +| `libs/a2ui/src/lib/types.ts` | Modified | Add `label?: string` to `A2uiActionMessage.action` | +| `libs/chat/src/lib/a2ui/build-action-message.ts` | Modified | New `deriveActionLabel()` helper + stamp `action.label` | +| `libs/chat/src/lib/a2ui/build-action-message.spec.ts` | Modified | New cases for label derivation | +| `libs/chat/src/lib/a2ui/action-label.ts` | Modified | Prefer `action.label`; delete `KNOWN_LABELS` | +| `cockpit/chat/a2ui/python/src/graph.py` | Modified | Add inline `generate_title` node + wire it | + +--- + +## Task 1: Add `label?: string` to `A2uiActionMessage` + +**Files:** +- Modify: `libs/a2ui/src/lib/types.ts` + +- [ ] **Step 1: Verify branch** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +``` + +- [ ] **Step 2: Find the existing `A2uiActionMessage` interface (around line 252)** and add the optional `label` field + +Find: + +```typescript +export interface A2uiActionMessage { + version: 'v1'; + action: { + name: string; + surfaceId: string; + sourceComponentId: string; + timestamp: string; + context: Record; + }; + metadata?: { + a2uiClientDataModel: A2uiClientDataModel; + }; +} +``` + +Replace with: + +```typescript +export interface A2uiActionMessage { + version: 'v1'; + action: { + name: string; + surfaceId: string; + sourceComponentId: string; + timestamp: string; + context: Record; + /** + * Optional human-friendly label for the action — typically derived + * from the source component's authored text (e.g. a Button's child + * Text literalString). Set by `buildA2uiActionMessage` when the + * source is a Button-with-Text-child; left undefined otherwise. + * Used by the chat-lib's transcript renderer to label the user + * bubble; backends may ignore. See spec + * 2026-05-19-llm-generated-labels-design.md. + */ + label?: string; + }; + metadata?: { + a2uiClientDataModel: A2uiClientDataModel; + }; +} +``` + +- [ ] **Step 3: Verify the file still type-checks** + +```bash +npx tsc --noEmit -p libs/a2ui/tsconfig.lib.json 2>&1 | tail -3 +``` + +Expected: no errors (silent success). + +- [ ] **Step 4: Commit** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +git add libs/a2ui/src/lib/types.ts +git commit -m "feat(a2ui): add optional A2uiActionMessage.action.label field" +``` + +--- + +## Task 2: Implement `deriveActionLabel` + stamp in `buildA2uiActionMessage` + +**Files:** +- Modify: `libs/chat/src/lib/a2ui/build-action-message.ts` + +- [ ] **Step 1: Verify branch** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +``` + +- [ ] **Step 2: Replace the file contents** + +The current file is short. Replace with: + +```typescript +// SPDX-License-Identifier: MIT +import type { A2uiSurface, A2uiActionMessage } from '@ngaf/a2ui'; + +function toDynamicValue(v: unknown): unknown { + if (typeof v === 'string') return { literalString: v }; + if (typeof v === 'number') return { literalNumber: v }; + if (typeof v === 'boolean') return { literalBoolean: v }; + return { literalString: String(v) }; +} + +/** + * Derive a human-readable label for an outgoing action by walking from + * the source component to its authored visible text. Today supported: + * Button → child Text → literalString. Returns null for other component + * types or when the linkage isn't well-formed; callers fall back to a + * camelCase humanization of `action.name`. + * + * Why: the chat-lib used to ship a hardcoded `KNOWN_LABELS` map + * (bookingSubmit → 'Search flights') that embedded app-specific + * knowledge in the primitive. The LLM that authors a surface already + * writes the Button's visible text — reuse it as the action label. + * See spec 2026-05-19-llm-generated-labels-design.md. + */ +function deriveActionLabel(surface: A2uiSurface, sourceId: string): string | null { + const source = surface.components.get(sourceId); + if (!source) return null; + const buttonProps = (source.component as { Button?: { child?: string } }).Button; + if (!buttonProps?.child) return null; + const labelText = surface.components.get(buttonProps.child); + if (!labelText) return null; + const textProps = (labelText.component as { Text?: { text?: { literalString?: string } } }).Text; + const literal = textProps?.text?.literalString; + return typeof literal === 'string' && literal.length > 0 ? literal : null; +} + +/** Builds an A2uiActionMessage from handler params and the current surface. + * The action.context is serialized as v1 DynamicValue-wrapped entries. + * Sets action.label when the source component is a Button with a Text + * child whose literalString is non-empty. */ +export function buildA2uiActionMessage( + params: Record, + surface: A2uiSurface, +): A2uiActionMessage { + const rawContext = (params['context'] as Record) ?? {}; + const wrappedContext: Record = {}; + for (const [k, v] of Object.entries(rawContext)) { + wrappedContext[k] = toDynamicValue(v); + } + + const sourceComponentId = params['sourceComponentId'] as string; + + const message: A2uiActionMessage = { + version: 'v1', + action: { + name: params['name'] as string, + surfaceId: surface.surfaceId, + sourceComponentId, + timestamp: new Date().toISOString(), + context: wrappedContext, + }, + }; + + const label = deriveActionLabel(surface, sourceComponentId); + if (label) message.action.label = label; + + if (surface.sendDataModel) { + message.metadata = { + a2uiClientDataModel: { + version: 'v1', + surfaces: { [surface.surfaceId]: surface.dataModel }, + }, + }; + } + return message; +} +``` + +- [ ] **Step 3: Verify type-checks** + +```bash +npx tsc --noEmit -p libs/chat/tsconfig.lib.json 2>&1 | tail -3 +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +git add libs/chat/src/lib/a2ui/build-action-message.ts +git commit -m "feat(chat): derive action.label from source Button's Text child" +``` + +--- + +## Task 3: Update `build-action-message.spec.ts` for label derivation + +**Files:** +- Modify: `libs/chat/src/lib/a2ui/build-action-message.spec.ts` + +- [ ] **Step 1: Verify branch** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +``` + +- [ ] **Step 2: Read the existing spec to understand the test helpers** + +```bash +head -30 libs/chat/src/lib/a2ui/build-action-message.spec.ts +``` + +Note the `makeSurface(components, dataModel?, sendDataModel?)` and `makeTextComp()` helpers — reuse them. + +- [ ] **Step 3: Append four new test cases inside the existing `describe('buildA2uiActionMessage (v1)', ...)` block** + +Find the closing `});` of the describe block and insert these tests just before it: + +```typescript + it('derives action.label from source Button child Text', () => { + const components: A2uiComponent[] = [ + { id: 'submit-btn', component: { Button: { child: 'submit-label', action: { name: 'formSubmit' } } } }, + { id: 'submit-label', component: { Text: { text: { literalString: 'Search flights' } } } }, + ]; + const surface = makeSurface(components); + const params = { surfaceId: 's1', sourceComponentId: 'submit-btn', name: 'formSubmit', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.label).toBe('Search flights'); + }); + + it('leaves action.label undefined when source is not a Button', () => { + const components: A2uiComponent[] = [ + { id: 'cb', component: { CheckBox: { label: { literalString: 'Agree' }, checked: { literalBoolean: false } } } }, + ]; + const surface = makeSurface(components); + const params = { surfaceId: 's1', sourceComponentId: 'cb', name: 'agreeToggle', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.label).toBeUndefined(); + }); + + it('leaves action.label undefined when Button has no child Text id', () => { + const components: A2uiComponent[] = [ + { id: 'submit-btn', component: { Button: { action: { name: 'formSubmit' } } as unknown as { child: string; action: { name: string } } } }, + ]; + const surface = makeSurface(components); + const params = { surfaceId: 's1', sourceComponentId: 'submit-btn', name: 'formSubmit', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.label).toBeUndefined(); + }); + + it('leaves action.label undefined when sourceComponentId does not exist in surface', () => { + const surface = makeSurface([makeTextComp()]); + const params = { surfaceId: 's1', sourceComponentId: 'ghost-id', name: 'click', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.label).toBeUndefined(); + }); +``` + +- [ ] **Step 4: Run the spec** + +```bash +npx nx run chat:test --testPathPattern=build-action-message 2>&1 | tail -10 +``` + +Expected: all tests pass (the 4 new ones + the existing 7). + +- [ ] **Step 5: Commit** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +git add libs/chat/src/lib/a2ui/build-action-message.spec.ts +git commit -m "test(chat): add cases for action.label derivation" +``` + +--- + +## Task 4: Remove `KNOWN_LABELS` from `action-label.ts`; prefer `action.label` + +**Files:** +- Modify: `libs/chat/src/lib/a2ui/action-label.ts` + +- [ ] **Step 1: Verify branch** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +``` + +- [ ] **Step 2: Replace the file contents** + +```typescript +// SPDX-License-Identifier: MIT +/** + * Synthesize a short human-readable label for a serialized A2UI action + * message, so the chat composition can render "Search flights" instead + * of a raw `{"version":"v1","action":...}` JSON dump as a user bubble. + * + * Per the A2UI v0.9 spec, action messages flow on the client → agent + * return channel and are framed as typed events (closer to tool calls + * than user utterances). The spec is silent on chat-bubble rendering; + * Google's "A2UI in Practice" article and the Stream Chat reference + * both warn against modeling actions as chat-history user turns. + * + * Label source priority: + * 1. `action.label` if present — populated by `buildA2uiActionMessage` + * from the source component's authored visible text (e.g. a + * Button's child Text literalString). This is the LLM-authored + * label and the preferred source. + * 2. CamelCase humanization of `action.name` (`bookingSubmit` → + * "Booking submit"). Used when no label was stamped — typically + * because the source component isn't a Button-with-Text-child. + * + * Returns null for any content that isn't a v1 A2UI action message; + * callers should fall back to the original content in that case. + * + * Design context: a previous iteration shipped a hardcoded + * `KNOWN_LABELS` map (bookingSubmit → 'Search flights') that embedded + * app-specific knowledge in the chat-lib primitive. That map was + * removed in favor of derivation from the authored UI; see spec + * 2026-05-19-llm-generated-labels-design.md. + * + * Sources: + * - https://a2ui.org/specification/v0.9-a2ui/ + * - https://medium.com/google-cloud/a2ui-in-practice-patterns-pitfalls-and-the-messages-that-hold-it-together-658720b83789 + * - https://getstream.io/blog/a2ui-chat-integration/ + */ + +export function a2uiActionLabel(content: string): string | null { + if (typeof content !== 'string' || content.length === 0) return null; + const trimmed = content.trimStart(); + if (!trimmed.startsWith('{')) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + if (parsed['version'] !== 'v1') return null; + const action = parsed['action']; + if (!isRecord(action)) return null; + const name = action['name']; + if (typeof name !== 'string' || name.length === 0) return null; + + // Preferred: label stamped at emit time by buildA2uiActionMessage from + // the source component's authored visible text. + const authoredLabel = action['label']; + if (typeof authoredLabel === 'string' && authoredLabel.length > 0) { + return authoredLabel; + } + + // Fallback: humanize the camelCase action name. + return humanizeCamelCase(name); +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +/** "bookingSubmit" → "Booking submit". "addToCart" → "Add to cart". */ +function humanizeCamelCase(name: string): string { + const spaced = name.replace(/([a-z])([A-Z])/g, '$1 $2'); + const lower = spaced.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); +} +``` + +Note what changed: +- `KNOWN_LABELS` and its helpers (`unwrapContextString`, `readLiteralString`) are removed +- Authored-label path added +- CamelCase humanizer is the only fallback + +- [ ] **Step 3: Run the existing spec (if there is one) — or add one** + +```bash +ls libs/chat/src/lib/a2ui/action-label.spec.ts 2>&1 | head -1 +``` + +If no spec exists, create one at `libs/chat/src/lib/a2ui/action-label.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { a2uiActionLabel } from './action-label'; + +describe('a2uiActionLabel', () => { + it('returns the authored label when action.label is present', () => { + const content = JSON.stringify({ + version: 'v1', + action: { name: 'bookingSubmit', label: 'Search flights' }, + }); + expect(a2uiActionLabel(content)).toBe('Search flights'); + }); + + it('falls back to camelCase humanization when no label', () => { + const content = JSON.stringify({ version: 'v1', action: { name: 'bookingSubmit' } }); + expect(a2uiActionLabel(content)).toBe('Booking submit'); + }); + + it('humanizes single-word action name', () => { + const content = JSON.stringify({ version: 'v1', action: { name: 'submit' } }); + expect(a2uiActionLabel(content)).toBe('Submit'); + }); + + it('humanizes multi-camel action name', () => { + const content = JSON.stringify({ version: 'v1', action: { name: 'addItemToCart' } }); + expect(a2uiActionLabel(content)).toBe('Add item to cart'); + }); + + it('returns null for non-v1 messages', () => { + expect(a2uiActionLabel('{"version":"v2","action":{"name":"x"}}')).toBeNull(); + }); + + it('returns null for non-action JSON', () => { + expect(a2uiActionLabel('{"foo":"bar"}')).toBeNull(); + }); + + it('returns null for plain text', () => { + expect(a2uiActionLabel('Hello world')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(a2uiActionLabel('')).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(a2uiActionLabel('{not json')).toBeNull(); + }); + + it('prefers authored label over humanization', () => { + // Even if name would humanize to "Foo bar", the label wins. + const content = JSON.stringify({ + version: 'v1', + action: { name: 'fooBar', label: 'Custom Label' }, + }); + expect(a2uiActionLabel(content)).toBe('Custom Label'); + }); + + it('falls back to humanization when label is empty string', () => { + const content = JSON.stringify({ + version: 'v1', + action: { name: 'fooBar', label: '' }, + }); + expect(a2uiActionLabel(content)).toBe('Foo bar'); + }); +}); +``` + +- [ ] **Step 4: Run the spec** + +```bash +npx nx run chat:test --testPathPattern=action-label 2>&1 | tail -5 +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +git add libs/chat/src/lib/a2ui/action-label.ts libs/chat/src/lib/a2ui/action-label.spec.ts +git commit -m "refactor(chat): drop KNOWN_LABELS; derive action label from authored UI" +``` + +--- + +## Task 5: Verify chat-lib builds + all tests pass + +**Files:** none (verification only). + +- [ ] **Step 1: Build chat lib** + +```bash +npx nx run chat:build 2>&1 | tail -3 +``` + +Expected: green. + +- [ ] **Step 2: Run all chat tests** + +```bash +npx nx run chat:test 2>&1 | tail -3 +``` + +Expected: green. + +- [ ] **Step 3: No commit (verification only).** + +--- + +## Task 6: Add inline `generate_title` node to c-a2ui graph + +**Files:** +- Modify: `cockpit/chat/a2ui/python/src/graph.py` + +- [ ] **Step 1: Verify branch** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +``` + +- [ ] **Step 2: Add `langgraph_sdk.get_client` to the imports near the top of the file** + +Find the existing imports block (around line 22-27) and add: + +```python +import os +# ... existing imports ... +from langgraph_sdk import get_client +``` + +If `os` is already imported, just add the `langgraph_sdk` line. + +- [ ] **Step 3: Add the `generate_title` node + its prompt constant** + +Find a good insertion point — after the existing `respond`-like terminal node definitions but before the `_builder = StateGraph(...)` block. (Search for the line that creates the StateGraph; insert immediately above it.) + +Add this block: + +```python +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata so the + sidenav shows something meaningful instead of a UUID slice. + + Idempotent — skips when metadata.thread_title already exists. Errors + are swallowed (title is a UX nicety, never a blocker). Runs after the + user-visible terminal node so it never blocks the response. See spec + 2026-05-19-llm-generated-labels-design.md. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL", "http://localhost:2024") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("thread_title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + # Skip action-message JSON (those flow as human-role too) + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"thread_title": title}) + except Exception as err: # noqa: BLE001 — title is a UX nicety; never block + _logger.warning("Thread title generation failed: %s", err) + return {} +``` + +- [ ] **Step 4: Wire `generate_title` into the builder** + +Find the existing builder block. It currently looks something like: + +```python +_builder = StateGraph(MessagesState) +_builder.add_node("router", router) +# ... other nodes ... +_builder.add_node("confirm_booking", confirm_booking) +# ... edges ... +_builder.add_edge("confirm_booking", END) +``` + +Add the node and rewire the terminal edges to go through `generate_title` instead of directly to END. + +Add this line after the last `_builder.add_node(...)`: + +```python +_builder.add_node("generate_title", generate_title) +``` + +Then change the terminal edges. Find each `_builder.add_edge("...", END)` and replace with two edges: + +```python +# BEFORE +_builder.add_edge("build_form", END) +_builder.add_edge("search_flights", END) +_builder.add_edge("confirm_booking", END) + +# AFTER +_builder.add_edge("build_form", "generate_title") +_builder.add_edge("search_flights", "generate_title") +_builder.add_edge("confirm_booking", "generate_title") +_builder.add_edge("generate_title", END) +``` + +(Use a `Read` first to see the exact terminal edges in your version of the file, then apply the same rewrite to each.) + +- [ ] **Step 5: Verify the file parses and the graph compiles** + +```bash +cd cockpit/chat/a2ui/python && uv run python -c " +from src.graph import graph, generate_title +print('TYPE:', type(graph).__name__) +nodes = sorted(graph.get_graph().nodes) +print('NODES:', nodes) +print('HAS_GENERATE_TITLE:', 'generate_title' in nodes) +" +``` + +Expected: `TYPE: CompiledStateGraph`, `HAS_GENERATE_TITLE: True`. The exact node list will include `__start__`, `__end__`, the original cap nodes (`router`, `build_form`, `search_flights`, `confirm_booking`), and `generate_title`. + +- [ ] **Step 6: Commit** + +```bash +test "$(git branch --show-current)" = "claude/llm-generated-labels" && echo OK || exit 1 +git add cockpit/chat/a2ui/python/src/graph.py +git commit -m "feat(c-a2ui): inline generate_title node writes LangGraph thread metadata" +``` + +--- + +## Task 7: REQUIRED — programmatic real-LLM smoke (c-a2ui) + +**Files:** none (verification only). Requires `OPENAI_API_KEY` in repo-root `.env` AND a running `langgraph dev` for c-a2ui (the node calls back into the SDK). + +- [ ] **Step 1: Boot c-a2ui's langgraph dev** + +```bash +lsof -t -i :5511 2>/dev/null | xargs kill -9 2>/dev/null +set -a; source .env; set +a +nohup pnpm nx run cockpit-chat-a2ui-python:serve > /tmp/a2ui-backend.log 2>&1 & +until grep -qE "Application started up" /tmp/a2ui-backend.log; do sleep 2; done +echo READY +``` + +- [ ] **Step 2: Smoke that title is written via SDK after a turn** + +```bash +cd cockpit/chat/a2ui/python && uv run python -c " +import asyncio, os +from langgraph_sdk import get_client + +os.environ['LANGGRAPH_API_URL'] = 'http://localhost:5511' + +async def main(): + client = get_client(url='http://localhost:5511') + thread = await client.threads.create() + tid = thread['thread_id'] + print('THREAD:', tid) + # Run the agent once + async for chunk in client.runs.stream( + thread_id=tid, + assistant_id='c-a2ui', + input={'messages': [{'role': 'user', 'content': 'I want to fly LAX to JFK'}]}, + stream_mode=['values'], + ): + pass + # Re-fetch the thread; title should now exist in metadata + thread = await client.threads.get(tid) + title = (thread.get('metadata') or {}).get('thread_title') + print('TITLE:', repr(title)) + assert title and len(title) > 0, 'title not written' + assert len(title) <= 80, f'title too long: {len(title)} chars' + # Idempotency: re-run shouldn't overwrite + await client.runs.stream( + thread_id=tid, + assistant_id='c-a2ui', + input={'messages': [{'role': 'user', 'content': 'Filter to cancelled flights'}]}, + stream_mode=['values'], + ).__anext__() + # Drain + async for chunk in client.runs.stream( + thread_id=tid, + assistant_id='c-a2ui', + input=None, + stream_mode=['values'], + ): + pass + thread2 = await client.threads.get(tid) + title2 = (thread2.get('metadata') or {}).get('thread_title') + print('TITLE_AFTER_2ND:', repr(title2)) + assert title2 == title, 'title overwritten on subsequent turn (idempotency broken)' + print('SMOKE_PASS') + +asyncio.run(main()) +" +``` + +Expected: `TITLE: ''`, `TITLE_AFTER_2ND: `, `SMOKE_PASS`. + +If the title is empty or the assertion fails, debug: print the langgraph dev backend log to see whether `generate_title` ran and what error it surfaced. + +- [ ] **Step 3: Smoke that buildA2uiActionMessage stamps the label** + +This is a TypeScript unit test added in Task 3. The same assertion was verified in Step 4 of Task 3. No additional Python smoke needed here. + +- [ ] **Step 4: Stop the backend** + +```bash +lsof -t -i :5511 2>/dev/null | xargs kill -9 2>/dev/null +``` + +- [ ] **Step 5: No commit (verification only).** + +--- + +## Task 8: Build verification + +**Files:** none. + +- [ ] **Step 1: Build chat lib** + +```bash +npx nx run chat:build 2>&1 | tail -3 +``` + +- [ ] **Step 2: Build c-a2ui python** + +```bash +npx nx run cockpit-chat-a2ui-python:build 2>&1 | tail -3 +``` + +- [ ] **Step 3: Build c-a2ui angular (sanity)** + +```bash +npx nx run cockpit-chat-a2ui-angular:build 2>&1 | tail -3 +``` + +- [ ] **Step 4: Production deploy manifest unchanged** + +```bash +npx tsx scripts/generate-shared-deployment-config.ts && git diff deployments/shared-dev/langgraph.json +``` + +Expected: empty diff. + +- [ ] **Step 5: No commit.** + +--- + +## Task 9: REQUIRED — chrome MCP end-to-end smoke + +**Files:** none. Boots c-a2ui dev backend + frontend; uses chrome MCP to verify both the action label and the thread title rendering. + +- [ ] **Step 1: Boot the dev servers from this branch's working tree** + +```bash +lsof -t -i :5511 -i :4511 2>/dev/null | xargs kill -9 2>/dev/null +set -a; source .env; set +a +nohup pnpm nx run cockpit-chat-a2ui-python:serve > /tmp/a2ui-backend.log 2>&1 & +nohup pnpm nx serve cockpit-chat-a2ui-angular --port 4511 > /tmp/a2ui-frontend.log 2>&1 & +until grep -qE "Application started up" /tmp/a2ui-backend.log && curl -s -o /dev/null http://localhost:4511/; do sleep 3; done +echo BOTH_READY +``` + +- [ ] **Step 2: Drive the flow via chrome MCP** + +1. Navigate to `http://localhost:4511/` +2. Click the `LAX → JFK` welcome chip +3. Wait ~15-20s for the booking form to render +4. Click `Search flights` on the form +5. Wait ~15-20s for the results surface to render + +Verify both: +- **Action label**: the user bubble for the Search submission shows `"Search flights"` (the Button's authored text), NOT raw JSON +- **Thread title**: after the turn completes, refresh the page (or wait for the sidenav to re-fetch). The thread in the sidenav should display a generated title like *"Flight LAX to JFK"* or similar 3-5 word summary, NOT the raw UUID slice. + +- [ ] **Step 3: If thread title doesn't appear in the sidenav** + +This may indicate a frontend SDK adapter issue (not sourcing `Thread.title` from `thread.metadata.thread_title`). Inspect: + +```bash +curl -s -X POST http://localhost:5511/threads/search -H 'Content-Type: application/json' -d '{"limit":1,"order":"desc","order_by":"updated_at"}' | python3 -m json.tool | head -20 +``` + +Confirm `metadata.thread_title` is present in the thread record. If yes but the sidenav doesn't show it, the langgraph adapter needs to map `metadata.thread_title` → `Thread.title`. Document the gap in the PR description and file as out-of-scope (the backend write is the deliverable for this PR). + +- [ ] **Step 4: Stop servers + cleanup** + +```bash +lsof -t -i :5511 -i :4511 2>/dev/null | xargs kill -9 2>/dev/null +rm -f /tmp/a2ui-backend.log /tmp/a2ui-frontend.log +``` + +--- + +## Task 10: Open PR + watch CI + merge (orchestrator) + +- [ ] **Step 1: Push branch** + +```bash +git push -u origin claude/llm-generated-labels +``` + +- [ ] **Step 2: Open PR** + +```bash +gh pr create --title "feat(chat + c-a2ui): LLM-generated labels — thread titles + drop KNOWN_LABELS" --body "$(cat <<'EOF' +## Summary +Implements the LLM-generated labels design (spec 2026-05-19-llm-generated-labels-design.md). Two related fixes in one PR: + +### 1. Action labels — drop hardcoded \`KNOWN_LABELS\` +PR #464 added a hardcoded map in libs/chat (\`bookingSubmit → 'Search flights'\`) embedding app-specific knowledge in the primitive. **Removed entirely.** Replaced with derivation from the **authored UI**: when emitting an \`A2uiActionMessage\`, \`buildA2uiActionMessage\` walks from the source Button to its child Text and stamps the literal as \`action.label\`. The transcript renderer (\`a2uiActionLabel\`) prefers \`action.label\` and falls back to camelCase humanization. The chat-lib stops knowing about specific app actions. + +### 2. Thread titles — inline LLM node per-cap +Added \`generate_title\` as a normal LangGraph node in \`cockpit/chat/a2ui/python/src/graph.py\` (Pattern D from the spec — fully inline, ~25 lines, visible in the topology). After the cap's terminal node, fires a cheap LLM call (\`gpt-5-mini\`) summarizing the first user message in 3-5 words and persists via \`client.threads.update(thread_id, metadata={'thread_title': ...})\`. Idempotent; errors swallowed; never blocks the user-visible response. + +## Files +- \`libs/a2ui/src/lib/types.ts\` — \`A2uiActionMessage.action.label?: string\` +- \`libs/chat/src/lib/a2ui/build-action-message.ts\` — \`deriveActionLabel\` helper + stamp +- \`libs/chat/src/lib/a2ui/build-action-message.spec.ts\` — 4 new cases +- \`libs/chat/src/lib/a2ui/action-label.ts\` — drop KNOWN_LABELS; prefer \`action.label\` +- \`libs/chat/src/lib/a2ui/action-label.spec.ts\` — new spec (11 cases) +- \`cockpit/chat/a2ui/python/src/graph.py\` — inline \`generate_title\` node + wiring + +## Test plan +- [x] chat-lib build green +- [x] chat-lib tests green (existing + 4 new + 11 new = expanded coverage) +- [x] c-a2ui python build green +- [x] c-a2ui angular build green +- [x] Shared deploy manifest unchanged +- [x] Programmatic real-LLM smoke (c-a2ui): turn 1 writes \`thread.metadata.thread_title\`; turn 2 doesn't overwrite (idempotency) +- [x] Chrome MCP smoke: form submit bubble shows 'Search flights' from authored Button text (not hardcoded map); sidenav shows generated title (or documented frontend SDK gap if missing) +- [ ] CI + +## Scope +**c-a2ui only** for the inline title node — proves the pattern. Other cockpit caps (c-generative-ui, c-tool-calls, c-subagents, c-interrupts, c-messages, c-input, c-debug, c-theming, c-threads, c-timeline) will adopt the same inline pattern in follow-up PRs, one at a time. + +Spec: \`docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md\` +Plan: \`docs/superpowers/plans/2026-05-19-llm-generated-labels.md\` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 3: Watch CI** + +```bash +gh pr checks --watch +``` + +- [ ] **Step 4: Merge on green** + +```bash +gh pr merge --squash --delete-branch +``` + +--- + +## Self-Review + +**Spec coverage:** +- Decision 1 (per-cap independence) → respected; no cross-cap imports introduced ✓ +- Decision 2 (fully inline) → Task 6 puts node + LLM call + SDK write all in graph.py ✓ +- Decision 3 (when title fires) → unconditional edge with idempotency in node body ✓ +- Decision 4 (gpt-5-mini default) → Task 6 ✓ +- Decision 5 (title prompt) → Task 6 (`_TITLE_PROMPT` constant) ✓ +- Decision 6 (SDK persistence) → Task 6 (`client.threads.update`) ✓ +- Decision 7 (failures swallowed) → Task 6 (try/except around the whole body) ✓ +- Decision 8 (Button → Text child source) → Task 2 (`deriveActionLabel` helper) ✓ +- Decision 9 (camelCase fallback) → Task 4 (action-label.ts) ✓ +- Decision 10 (kill KNOWN_LABELS) → Task 4 (removed entirely) ✓ +- Decision 11 (wire-compat: `label?: string` optional) → Task 1 ✓ +- Decision 12 (c-a2ui only) → Task 6 only touches c-a2ui ✓ + +**Placeholder scan:** No TBDs. Every code-modifying step contains the full code to add/replace. Task 6 Step 4 says "Use a `Read` first to see the exact terminal edges" — that's a direct instruction, not a placeholder. + +**Type consistency:** +- `A2uiActionMessage.action.label?: string` (Task 1) matches the read in `a2uiActionLabel` (Task 4 — `action['label']`) and the write in `buildA2uiActionMessage` (Task 2 — `message.action.label = label`). +- `deriveActionLabel(surface, sourceId): string | null` (Task 2) — caller (Task 2 same file) checks truthiness; no type mismatch. +- `generate_title(state: MessagesState, config) -> dict` (Task 6) — matches LangGraph's expected node signature; `config` parameter is auto-injected by LangGraph runtime. +- All test cases (Tasks 3, 4) use the existing test helpers (`makeSurface`, `makeTextComp`) where applicable; new fixtures are inline and consistent with the production types. From 7870d99861eb4165e5a953735fc04f4ab1b4445e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 12:49:28 -0700 Subject: [PATCH 3/9] feat(a2ui): add optional A2uiActionMessage.action.label field --- libs/a2ui/src/lib/types.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libs/a2ui/src/lib/types.ts b/libs/a2ui/src/lib/types.ts index 841340fb..1e8c93f8 100644 --- a/libs/a2ui/src/lib/types.ts +++ b/libs/a2ui/src/lib/types.ts @@ -257,6 +257,16 @@ export interface A2uiActionMessage { sourceComponentId: string; timestamp: string; context: Record; + /** + * Optional human-friendly label for the action — typically derived + * from the source component's authored text (e.g. a Button's child + * Text literalString). Set by `buildA2uiActionMessage` when the + * source is a Button-with-Text-child; left undefined otherwise. + * Used by the chat-lib's transcript renderer to label the user + * bubble; backends may ignore. See spec + * 2026-05-19-llm-generated-labels-design.md. + */ + label?: string; }; metadata?: { a2uiClientDataModel: A2uiClientDataModel; From 04c0636ac2ede46dc58eaae1ca4baf0d82703a20 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 12:50:02 -0700 Subject: [PATCH 4/9] feat(chat): derive action.label from source Button's Text child --- .../chat/src/lib/a2ui/build-action-message.ts | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/libs/chat/src/lib/a2ui/build-action-message.ts b/libs/chat/src/lib/a2ui/build-action-message.ts index 1e7c671e..1375d2e2 100644 --- a/libs/chat/src/lib/a2ui/build-action-message.ts +++ b/libs/chat/src/lib/a2ui/build-action-message.ts @@ -8,8 +8,35 @@ function toDynamicValue(v: unknown): unknown { return { literalString: String(v) }; } +/** + * Derive a human-readable label for an outgoing action by walking from + * the source component to its authored visible text. Today supported: + * Button → child Text → literalString. Returns null for other component + * types or when the linkage isn't well-formed; callers fall back to a + * camelCase humanization of `action.name`. + * + * Why: the chat-lib used to ship a hardcoded `KNOWN_LABELS` map + * (bookingSubmit → 'Search flights') that embedded app-specific + * knowledge in the primitive. The LLM that authors a surface already + * writes the Button's visible text — reuse it as the action label. + * See spec 2026-05-19-llm-generated-labels-design.md. + */ +function deriveActionLabel(surface: A2uiSurface, sourceId: string): string | null { + const source = surface.components.get(sourceId); + if (!source) return null; + const buttonProps = (source.component as { Button?: { child?: string } }).Button; + if (!buttonProps?.child) return null; + const labelText = surface.components.get(buttonProps.child); + if (!labelText) return null; + const textProps = (labelText.component as { Text?: { text?: { literalString?: string } } }).Text; + const literal = textProps?.text?.literalString; + return typeof literal === 'string' && literal.length > 0 ? literal : null; +} + /** Builds an A2uiActionMessage from handler params and the current surface. - * The action.context is serialized as v1 DynamicValue-wrapped entries. */ + * The action.context is serialized as v1 DynamicValue-wrapped entries. + * Sets action.label when the source component is a Button with a Text + * child whose literalString is non-empty. */ export function buildA2uiActionMessage( params: Record, surface: A2uiSurface, @@ -20,16 +47,22 @@ export function buildA2uiActionMessage( wrappedContext[k] = toDynamicValue(v); } + const sourceComponentId = params['sourceComponentId'] as string; + const message: A2uiActionMessage = { version: 'v1', action: { name: params['name'] as string, surfaceId: surface.surfaceId, - sourceComponentId: params['sourceComponentId'] as string, + sourceComponentId, timestamp: new Date().toISOString(), context: wrappedContext, }, }; + + const label = deriveActionLabel(surface, sourceComponentId); + if (label) message.action.label = label; + if (surface.sendDataModel) { message.metadata = { a2uiClientDataModel: { From e94ba90e546ca82b2a5bdf8b6cbe9c8edf3e2ca4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 12:50:55 -0700 Subject: [PATCH 5/9] test(chat): add cases for action.label derivation --- .../src/lib/a2ui/build-action-message.spec.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/libs/chat/src/lib/a2ui/build-action-message.spec.ts b/libs/chat/src/lib/a2ui/build-action-message.spec.ts index 5d488195..51a31a41 100644 --- a/libs/chat/src/lib/a2ui/build-action-message.spec.ts +++ b/libs/chat/src/lib/a2ui/build-action-message.spec.ts @@ -97,4 +97,42 @@ describe('buildA2uiActionMessage (v1)', () => { const msg = buildA2uiActionMessage(params, surface); expect(msg.action.context).toEqual({}); }); + + it('derives action.label from source Button child Text', () => { + const components: A2uiComponent[] = [ + { id: 'submit-btn', component: { Button: { child: 'submit-label', action: { name: 'formSubmit' } } } }, + { id: 'submit-label', component: { Text: { text: { literalString: 'Search flights' } } } }, + ]; + const surface = makeSurface(components); + const params = { surfaceId: 's1', sourceComponentId: 'submit-btn', name: 'formSubmit', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.label).toBe('Search flights'); + }); + + it('leaves action.label undefined when source is not a Button', () => { + const components: A2uiComponent[] = [ + { id: 'cb', component: { CheckBox: { label: { literalString: 'Agree' }, checked: { literalBoolean: false } } } }, + ]; + const surface = makeSurface(components); + const params = { surfaceId: 's1', sourceComponentId: 'cb', name: 'agreeToggle', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.label).toBeUndefined(); + }); + + it('leaves action.label undefined when Button has no child Text id', () => { + const components: A2uiComponent[] = [ + { id: 'submit-btn', component: { Button: { action: { name: 'formSubmit' } } as unknown as { child: string; action: { name: string } } } }, + ]; + const surface = makeSurface(components); + const params = { surfaceId: 's1', sourceComponentId: 'submit-btn', name: 'formSubmit', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.label).toBeUndefined(); + }); + + it('leaves action.label undefined when sourceComponentId does not exist in surface', () => { + const surface = makeSurface([makeTextComp()]); + const params = { surfaceId: 's1', sourceComponentId: 'ghost-id', name: 'click', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.label).toBeUndefined(); + }); }); From 6cc472c047e3fd7ecbce3b8a2f80a91b80e90720 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 12:52:09 -0700 Subject: [PATCH 6/9] refactor(chat): drop KNOWN_LABELS; derive action label from authored UI --- libs/chat/src/lib/a2ui/action-label.spec.ts | 65 ++++++++++++++++++ libs/chat/src/lib/a2ui/action-label.ts | 74 +++++++-------------- 2 files changed, 90 insertions(+), 49 deletions(-) create mode 100644 libs/chat/src/lib/a2ui/action-label.spec.ts diff --git a/libs/chat/src/lib/a2ui/action-label.spec.ts b/libs/chat/src/lib/a2ui/action-label.spec.ts new file mode 100644 index 00000000..f0767487 --- /dev/null +++ b/libs/chat/src/lib/a2ui/action-label.spec.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { a2uiActionLabel } from './action-label'; + +describe('a2uiActionLabel', () => { + it('returns the authored label when action.label is present', () => { + const content = JSON.stringify({ + version: 'v1', + action: { name: 'bookingSubmit', label: 'Search flights' }, + }); + expect(a2uiActionLabel(content)).toBe('Search flights'); + }); + + it('falls back to camelCase humanization when no label', () => { + const content = JSON.stringify({ version: 'v1', action: { name: 'bookingSubmit' } }); + expect(a2uiActionLabel(content)).toBe('Booking submit'); + }); + + it('humanizes single-word action name', () => { + const content = JSON.stringify({ version: 'v1', action: { name: 'submit' } }); + expect(a2uiActionLabel(content)).toBe('Submit'); + }); + + it('humanizes multi-camel action name', () => { + const content = JSON.stringify({ version: 'v1', action: { name: 'addItemToCart' } }); + expect(a2uiActionLabel(content)).toBe('Add item to cart'); + }); + + it('returns null for non-v1 messages', () => { + expect(a2uiActionLabel('{"version":"v2","action":{"name":"x"}}')).toBeNull(); + }); + + it('returns null for non-action JSON', () => { + expect(a2uiActionLabel('{"foo":"bar"}')).toBeNull(); + }); + + it('returns null for plain text', () => { + expect(a2uiActionLabel('Hello world')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(a2uiActionLabel('')).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(a2uiActionLabel('{not json')).toBeNull(); + }); + + it('prefers authored label over humanization', () => { + // Even if name would humanize to "Foo bar", the label wins. + const content = JSON.stringify({ + version: 'v1', + action: { name: 'fooBar', label: 'Custom Label' }, + }); + expect(a2uiActionLabel(content)).toBe('Custom Label'); + }); + + it('falls back to humanization when label is empty string', () => { + const content = JSON.stringify({ + version: 'v1', + action: { name: 'fooBar', label: '' }, + }); + expect(a2uiActionLabel(content)).toBe('Foo bar'); + }); +}); diff --git a/libs/chat/src/lib/a2ui/action-label.ts b/libs/chat/src/lib/a2ui/action-label.ts index c6835b02..2de18d51 100644 --- a/libs/chat/src/lib/a2ui/action-label.ts +++ b/libs/chat/src/lib/a2ui/action-label.ts @@ -10,8 +10,23 @@ * Google's "A2UI in Practice" article and the Stream Chat reference * both warn against modeling actions as chat-history user turns. * - * This helper returns null for any content that isn't a v1 A2UI action - * message; callers should fall back to the original content in that case. + * Label source priority: + * 1. `action.label` if present — populated by `buildA2uiActionMessage` + * from the source component's authored visible text (e.g. a + * Button's child Text literalString). This is the LLM-authored + * label and the preferred source. + * 2. CamelCase humanization of `action.name` (`bookingSubmit` → + * "Booking submit"). Used when no label was stamped — typically + * because the source component isn't a Button-with-Text-child. + * + * Returns null for any content that isn't a v1 A2UI action message; + * callers should fall back to the original content in that case. + * + * Design context: a previous iteration shipped a hardcoded + * `KNOWN_LABELS` map (bookingSubmit → 'Search flights') that embedded + * app-specific knowledge in the chat-lib primitive. That map was + * removed in favor of derivation from the authored UI; see spec + * 2026-05-19-llm-generated-labels-design.md. * * Sources: * - https://a2ui.org/specification/v0.9-a2ui/ @@ -19,20 +34,8 @@ * - https://getstream.io/blog/a2ui-chat-integration/ */ -/** Known action names that have a curated label. The default for any - * other action name is a camelCase → "Camel Case" humanization. */ -const KNOWN_LABELS: Record string> = { - bookingSubmit: () => 'Search flights', - flightSelect: (ctx) => { - const id = unwrapContextString(ctx, 'flightId') ?? unwrapContextString(ctx, 'flight_id'); - return id ? `Selected flight ${id}` : 'Selected flight'; - }, - modifySearch: () => 'Modify search', -}; - export function a2uiActionLabel(content: string): string | null { if (typeof content !== 'string' || content.length === 0) return null; - // Cheap pre-check to skip parsing non-JSON content (markdown, prose, etc). const trimmed = content.trimStart(); if (!trimmed.startsWith('{')) return null; let parsed: unknown; @@ -48,8 +51,14 @@ export function a2uiActionLabel(content: string): string | null { const name = action['name']; if (typeof name !== 'string' || name.length === 0) return null; - const known = KNOWN_LABELS[name]; - if (known) return known(action['context']); + // Preferred: label stamped at emit time by buildA2uiActionMessage from + // the source component's authored visible text. + const authoredLabel = action['label']; + if (typeof authoredLabel === 'string' && authoredLabel.length > 0) { + return authoredLabel; + } + + // Fallback: humanize the camelCase action name. return humanizeCamelCase(name); } @@ -63,36 +72,3 @@ function humanizeCamelCase(name: string): string { const lower = spaced.toLowerCase(); return lower.charAt(0).toUpperCase() + lower.slice(1); } - -/** - * Extract a string-typed value from an A2UI context structure. The v1 - * wire shape carries each value as a DynamicValue (`{literalString: ...}`, - * `{literalNumber: ...}`, `{path: ...}`); we want the literal string only. - * - * Context can be either: - * - a dict: `{ key1: {literalString: "..."}, key2: ... }` (compact form) - * - an array of entries: `[{key, value: {literalString: "..."}}, ...]` - * (the spec's canonical wire shape for A2uiActionContextEntry[]) - */ -function unwrapContextString(context: unknown, key: string): string | null { - if (Array.isArray(context)) { - const entry = context.find( - (e): e is { key: unknown; value: unknown } => - isRecord(e) && (e as Record)['key'] === key, - ); - if (!entry) return null; - return readLiteralString(entry.value); - } - if (isRecord(context)) { - return readLiteralString(context[key]); - } - return null; -} - -function readLiteralString(value: unknown): string | null { - if (typeof value === 'string') return value; - if (isRecord(value) && typeof value['literalString'] === 'string') { - return value['literalString'] as string; - } - return null; -} From 737e9132690f4f9162c43eb6fd2202a931e158e2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 12:53:18 -0700 Subject: [PATCH 7/9] feat(c-a2ui): inline generate_title node writes LangGraph thread metadata --- cockpit/chat/a2ui/python/src/graph.py | 61 +++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index 7842f402..951dc706 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -18,6 +18,7 @@ import json import logging +import os import re from typing import Any, Literal @@ -25,6 +26,7 @@ from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState, END from langgraph.types import Command +from langgraph_sdk import get_client from pydantic import BaseModel, Field, ValidationError, field_validator @@ -757,14 +759,67 @@ def route(state: MessagesState) -> Command[Literal["build_form", "search_flights return Command(goto="build_form") +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata so the + sidenav shows something meaningful instead of a UUID slice. + + Idempotent — skips when metadata.thread_title already exists. Errors + are swallowed (title is a UX nicety, never a blocker). Runs after the + user-visible terminal node so it never blocks the response. See spec + 2026-05-19-llm-generated-labels-design.md. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL", "http://localhost:2024") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("thread_title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + # Skip action-message JSON (those flow as human-role too) + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"thread_title": title}) + except Exception as err: # noqa: BLE001 — title is a UX nicety; never block + _logger.warning("Thread title generation failed: %s", err) + return {} + + _builder = StateGraph(MessagesState) _builder.add_node("route", route) _builder.add_node("build_form", build_form) _builder.add_node("search_flights", search_flights) _builder.add_node("confirm_booking", confirm_booking) +_builder.add_node("generate_title", generate_title) _builder.set_entry_point("route") -_builder.add_edge("build_form", END) -_builder.add_edge("search_flights", END) -_builder.add_edge("confirm_booking", END) +_builder.add_edge("build_form", "generate_title") +_builder.add_edge("search_flights", "generate_title") +_builder.add_edge("confirm_booking", "generate_title") +_builder.add_edge("generate_title", END) graph = _builder.compile() From 495bf4d06b74e637fd736da32be2ac1078398785 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 13:04:30 -0700 Subject: [PATCH 8/9] fix(chat): action.label derivation also accepts raw-string Text.text shorthand --- .../src/lib/a2ui/build-action-message.spec.ts | 16 +++++++++++++++- libs/chat/src/lib/a2ui/build-action-message.ts | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/libs/chat/src/lib/a2ui/build-action-message.spec.ts b/libs/chat/src/lib/a2ui/build-action-message.spec.ts index 51a31a41..d0c194c1 100644 --- a/libs/chat/src/lib/a2ui/build-action-message.spec.ts +++ b/libs/chat/src/lib/a2ui/build-action-message.spec.ts @@ -98,7 +98,7 @@ describe('buildA2uiActionMessage (v1)', () => { expect(msg.action.context).toEqual({}); }); - it('derives action.label from source Button child Text', () => { + it('derives action.label from source Button child Text (wrapped literalString)', () => { const components: A2uiComponent[] = [ { id: 'submit-btn', component: { Button: { child: 'submit-label', action: { name: 'formSubmit' } } } }, { id: 'submit-label', component: { Text: { text: { literalString: 'Search flights' } } } }, @@ -109,6 +109,20 @@ describe('buildA2uiActionMessage (v1)', () => { expect(msg.action.label).toBe('Search flights'); }); + it('derives action.label from source Button child Text (raw string shorthand)', () => { + // The LLM sometimes authors `text` as a raw string (ergonomic shorthand) + // rather than the canonical `{ literalString }` shape. Both are valid in + // the wild — the derivation accepts both. Real example from c-a2ui. + const components: A2uiComponent[] = [ + { id: 'submit', component: { Button: { child: 'submit_label', action: { name: 'bookingSubmit' } } } }, + { id: 'submit_label', component: { Text: { text: 'Search flights' as unknown as { literalString: string } } } }, + ]; + const surface = makeSurface(components); + const params = { surfaceId: 's1', sourceComponentId: 'submit', name: 'bookingSubmit', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.label).toBe('Search flights'); + }); + it('leaves action.label undefined when source is not a Button', () => { const components: A2uiComponent[] = [ { id: 'cb', component: { CheckBox: { label: { literalString: 'Agree' }, checked: { literalBoolean: false } } } }, diff --git a/libs/chat/src/lib/a2ui/build-action-message.ts b/libs/chat/src/lib/a2ui/build-action-message.ts index 1375d2e2..a0d95756 100644 --- a/libs/chat/src/lib/a2ui/build-action-message.ts +++ b/libs/chat/src/lib/a2ui/build-action-message.ts @@ -28,9 +28,20 @@ function deriveActionLabel(surface: A2uiSurface, sourceId: string): string | nul if (!buttonProps?.child) return null; const labelText = surface.components.get(buttonProps.child); if (!labelText) return null; - const textProps = (labelText.component as { Text?: { text?: { literalString?: string } } }).Text; - const literal = textProps?.text?.literalString; - return typeof literal === 'string' && literal.length > 0 ? literal : null; + const textProps = (labelText.component as { Text?: { text?: unknown } }).Text; + if (!textProps) return null; + // `text` may be either a raw string (LLM-author ergonomic shorthand) or + // a wrapped DynamicString `{ literalString: "..." }` (canonical v1 shape). + // Accept both so the label survives whichever form the LLM happens to emit. + const text = textProps.text; + if (typeof text === 'string') { + return text.length > 0 ? text : null; + } + if (text && typeof text === 'object' && typeof (text as { literalString?: unknown }).literalString === 'string') { + const literal = (text as { literalString: string }).literalString; + return literal.length > 0 ? literal : null; + } + return null; } /** Builds an A2uiActionMessage from handler params and the current surface. From d96179c67557682dc5e58cd00af000895ed0bc9f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 20:09:36 +0000 Subject: [PATCH 9/9] chore(docs): regenerate api docs --- apps/website/content/docs/chat/api/api-docs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 3e8ca377..24ff5899 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -6586,7 +6586,7 @@ { "name": "buildA2uiActionMessage", "kind": "function", - "description": "Builds an A2uiActionMessage from handler params and the current surface.\n The action.context is serialized as v1 DynamicValue-wrapped entries.", + "description": "Builds an A2uiActionMessage from handler params and the current surface.\n The action.context is serialized as v1 DynamicValue-wrapped entries.\n Sets action.label when the source component is a Button with a Text\n child whose literalString is non-empty.", "signature": "buildA2uiActionMessage(params: Record, surface: A2uiSurface): A2uiActionMessage", "params": [ {