From 2febfb9f588755fe02211117c0dfeff9778f861f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 21:04:59 -0700 Subject: [PATCH 01/16] =?UTF-8?q?docs(ag-ui):=20correct=20F3=20=E2=80=94?= =?UTF-8?q?=20stray=20'Success'=20was=20a=20truncated=20delta,=20real=20ga?= =?UTF-8?q?p=20is=20the=20error=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../specs/2026-06-11-ag-ui-capability-findings.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md b/docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md index e130f12e..9469c8a4 100644 --- a/docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md +++ b/docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md @@ -19,7 +19,7 @@ | 8 | Gen-UI: json-render | βœ… (renders) + πŸ”Ά F4 | interactive dashboard rendered (tabs/sliders/checkboxes); metric values show `[object Object]` β€” **reproduces identically on canonical prod** β†’ shared render/graph issue, not AG-UI | | 9 | Theme presets + dark/light | βœ… + πŸ”Ά F2 | toggle + URL knobs sync; itinerary panel & mode hosts stay dark in light scheme (example CSS) | | 10 | Research subagent | βœ… (runs) + πŸ”Ά F5 | run completed with structured summary; renders as plain tool row β€” no subagent card (known adapter gap: no subagent metadata over AG-UI) | -| 11 | Stop mid-stream | πŸ”΄ F3 | stream halts, but: red error banner "BodyStreamBuffer was aborted" + stray "Success" text appended to the truncated message | +| 11 | Stop mid-stream | πŸ”΄ F3 | stream halts, but a red error banner "BodyStreamBuffer was aborted" presents the user's own stop as a failure | | 12 | Regenerate | βœ… | replaced the aborted message; fresh complete response, no artifacts | | 13 | Error recovery | βœ… | e2e (pre-verified) | | 14 | Client tools β€” embed | βœ… | #655 e2e (3 specs) | @@ -35,8 +35,8 @@ Every conversation-state send leaves the sent text in the textarea. Probed live: ### F2 β€” Light scheme not honored by example chrome (low, example-level) `examples/ag-ui` itinerary panel (#655) and the popup/sidebar mode host backgrounds use hardcoded dark colors; with `scheme=light` the toolbar+chat go light but the panel and mode hosts stay dark. Fix with theme-aware CSS keyed off `data-threadplane-chat-theme`. -### F3 β€” Stop surfaces as error + stray text β€” `@threadplane/ag-ui` adapter (high) -User-initiated stop renders a red "BodyStreamBuffer was aborted" error banner and appended a stray "Success" item to the truncated message. The adapter should treat AbortError as graceful cancellation (no error state) and finalize the partial message without appending terminal-event artifacts. Canonical LangGraph stop is graceful β€” parity gap. +### F3 β€” Stop surfaces as an error β€” `@threadplane/ag-ui` adapter (high) +User-initiated stop renders a red "BodyStreamBuffer was aborted" error banner: `source.abortRun()` makes the underlying AG-UI client invoke `onRunFailed`, and the adapter's handler unconditionally sets `status: 'error'` + `error`. Fix: `stop()` sets an abort-requested flag; `onRunFailed`/submit-catch treat abort errors (flag set, or `AbortError`/abort-message) as graceful cancellation β€” status `idle`, no error, distinct telemetry. Canonical LangGraph stop is graceful β€” parity gap. (An earlier note about stray "Success" text was a misread: it was the final streamed delta "Succe|ssive…" truncated mid-word by the abort β€” expected.) ### F4 β€” json-render binds objects as text β€” shared render/graph issue (medium, NOT AG-UI-specific) Generated dashboard specs render `[object Object]` for metric values and a literal `trending_up` icon name. Reproduces byte-for-byte on `demo.threadplane.ai` (langgraph transport), so the bug is in the json-render engine's value/binding resolution (`@threadplane/render`) and/or the graph's `json_render` spec schema β€” not the AG-UI adapter. Track as its own fix; both demos benefit. From 0f18eaad09b081a2dbf71b5ffacf94f0ac8f4f02 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 21:08:47 -0700 Subject: [PATCH 02/16] docs(ag-ui): Phase 3 gap-closure implementation plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-06-11-ag-ui-gap-closure-p3.md | 605 ++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-ag-ui-gap-closure-p3.md diff --git a/docs/superpowers/plans/2026-06-11-ag-ui-gap-closure-p3.md b/docs/superpowers/plans/2026-06-11-ag-ui-gap-closure-p3.md new file mode 100644 index 00000000..ddbb5cfd --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-ag-ui-gap-closure-p3.md @@ -0,0 +1,605 @@ +# AG-UI Gap Closure Phase 3 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:** Close audit gaps F1, F2, F3, F4, F6 from `docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md` (F5 β€” subagent card β€” is deferred to Phase 4 design). + +**Architecture:** Three small library fixes (`@threadplane/chat` chat-input binding, `@threadplane/ag-ui` abort handling, chat markdown track expressions), one example-level fix (`data-color-scheme` parity with the canonical shell), and one bounded investigate-then-fix (json-render object values, shared with the canonical demo). Each fix lands TDD-first where a test can express it. + +**Tech Stack:** Angular 21 signals (zoneless, OnPush), vitest + TestBed for lib units, Playwright + aimock for e2e, nx for gates. + +**Branch:** `ag-ui-gap-closure-p3` off `origin/main` (the worktree's current branch holds the merged findings PR #660 β€” do not stack on it). + +**Context for the engineer:** +- The audit evidence and root-cause notes live in `docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md`. Read it first. +- F1 was probed live: after Enter-submit, `chat-input`'s `messageText()` signal is `""` but the DOM textarea still shows the text. The `[ngModel]`/`(ngModelChange)` pair fails to write the programmatic clear back to the view (zoneless + OnPush). Reproduces on production `demo.threadplane.ai` mid-thread. +- F2 root cause: `examples/ag-ui/angular/src/styles.css` keys the page scheme off `html[data-color-scheme]`, but `AgUiShell` only sets `data-theme` + `data-threadplane-chat-theme`. The canonical shell sets `data-color-scheme` in both its runtime effect (`examples/chat/angular/src/app/shell/demo-shell.component.ts:133`) and an index.html pre-bootstrap script. The ag-ui example has neither. +- F3 root cause: `stop()` calls `source.abortRun()`; the AG-UI HttpAgent then invokes `onRunFailed({error: "BodyStreamBuffer was aborted"})`, and the adapter's handler unconditionally sets `status: 'error'`. +- F6: identity-tracked `@for` in `libs/chat/src/lib/markdown/markdown-children.component.ts:31` (`track $any(child)`) and `libs/chat/src/lib/markdown/views/markdown-table.component.ts:20` (`track $any(row)`). Markdown re-parses every stream delta, producing new child objects each time β†’ NG0956 + full DOM re-creation per chunk. + +--- + +### Task 0: Branch setup + +- [ ] **Step 1: Create the phase branch off latest main** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/ag-ui-demo-toolbar +git fetch origin main +git checkout -b ag-ui-gap-closure-p3 origin/main +``` + +Expected: `Switched to a new branch 'ag-ui-gap-closure-p3'`. + +--- + +### Task 1: F2 β€” `data-color-scheme` parity (example-level) + +**Files:** +- Modify: `examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts` (theme/scheme effect, ~line 140) +- Modify: `examples/ag-ui/angular/src/index.html` (add pre-bootstrap script) +- Modify: `examples/ag-ui/angular/e2e/toolbar.spec.ts` (regression assertion) + +- [ ] **Step 1: Write the failing e2e assertion** + +Append to `examples/ag-ui/angular/e2e/toolbar.spec.ts`: + +```ts +test('light scheme flips the page background (data-color-scheme parity)', async ({ page }) => { + await openDemo(page, '/embed?scheme=light&theme=default-light'); + await expect(page.locator('html')).toHaveAttribute('data-color-scheme', 'light'); + // The page background var must resolve to the light value, not the dark default. + const bg = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--demo-page-bg').trim(), + ); + expect(bg).toBe('#ffffff'); +}); +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run (aimock harness β€” see `examples/ag-ui/angular/e2e/README.md`; kill any orphaned uvicorn on :8000 / serve first, `NX_DAEMON=false`): + +```bash +NX_DAEMON=false npx nx e2e examples-ag-ui-angular-e2e --grep "data-color-scheme parity" +``` + +Expected: FAIL β€” `data-color-scheme` attribute missing. + +- [ ] **Step 3: Set the attribute in the shell effect** + +In `examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts`, the constructor effect currently reads: + +```ts + // Reflect theme + scheme onto exactly like the canonical shell. + effect(() => { + const html = this.document.documentElement; + html.setAttribute('data-theme', this.theme()); + const scheme = this.colorScheme(); + html.setAttribute('data-threadplane-chat-theme', scheme); +``` + +Add the page-scheme attribute alongside the chat one: + +```ts + // Reflect theme + scheme onto exactly like the canonical shell. + effect(() => { + const html = this.document.documentElement; + html.setAttribute('data-theme', this.theme()); + const scheme = this.colorScheme(); + html.setAttribute('data-threadplane-chat-theme', scheme); + html.setAttribute('data-color-scheme', scheme); +``` + +- [ ] **Step 4: Add the pre-bootstrap script to index.html** + +Replace the `` of `examples/ag-ui/angular/src/index.html` with (mirrors the canonical script; note the ag-ui persistence key): + +```html + + + AG-UI Chat β€” Threadplane Example + + + + + +``` + +- [ ] **Step 5: Run the e2e spec to verify it passes** + +```bash +NX_DAEMON=false npx nx e2e examples-ag-ui-angular-e2e --grep "data-color-scheme parity" +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts examples/ag-ui/angular/src/index.html examples/ag-ui/angular/e2e/toolbar.spec.ts +git commit -m "fix(examples/ag-ui): set data-color-scheme so light mode reaches the page chrome (F2)" +``` + +--- + +### Task 2: F1 β€” chat-input clears the textarea after submit (`@threadplane/chat`) + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts` +- Test: `libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts` +- Modify: `examples/ag-ui/angular/e2e/send-receive.spec.ts` (integration regression) + +- [ ] **Step 1: Write the failing unit test** + +Append to the component-level describe blocks in `chat-input.component.spec.ts` (the file already imports `TestBed`, `ChatInputComponent`, and `mockAgent`; follow the file's existing component-test setup β€” use `fixture.componentRef.setInput('agent', agent)`): + +```ts +describe('ChatInputComponent β€” clears view on submit (F1 regression)', () => { + it('empties both the signal and the textarea DOM value after Enter submit', async () => { + const agent = mockAgent(); + TestBed.configureTestingModule({ imports: [ChatInputComponent] }); + const fixture = TestBed.createComponent(ChatInputComponent); + fixture.componentRef.setInput('agent', agent); + fixture.detectChanges(); + + const textarea: HTMLTextAreaElement = + fixture.nativeElement.querySelector('textarea'); + // Simulate real typing: set the DOM value and fire the input event. + textarea.value = 'hello world'; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + expect(fixture.componentInstance.messageText()).toBe('hello world'); + + // Enter-key submit (the (keydown.enter) path). + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(agent.submitCalls).toHaveLength(1); + expect(fixture.componentInstance.messageText()).toBe(''); + // The DOM must reflect the clear β€” this is the bug: ngModel never + // writes the programmatic '' back to the view. + expect(textarea.value).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails on the DOM assertion** + +```bash +npx nx test chat -- --run --testNamePattern "F1 regression" +``` + +Expected: FAIL on `expect(textarea.value).toBe('')` (received `'hello world'`). + +- [ ] **Step 3: Replace the ngModel pair with direct value/input bindings** + +In `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts`: + +a) Remove `import { FormsModule } from '@angular/forms';` and remove `FormsModule` from the component's `imports: [...]` array (it exists solely for this textarea). + +b) In the template, replace: + +```html + From 5bf93d3579dfa72be4db178e068f70b99fadb499 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 23:08:22 -0700 Subject: [PATCH 06/16] fix(ag-ui): treat stop()-induced aborts as graceful cancellation (F3) abortRun() makes the AG-UI client report onRunFailed('BodyStreamBuffer was aborted'), which rendered a red error banner for a user-initiated stop. Track abort intent and settle the store as idle instead. Co-Authored-By: Claude Fable 5 --- libs/ag-ui/src/lib/to-agent.spec.ts | 40 +++++++++++++++++++++ libs/ag-ui/src/lib/to-agent.ts | 55 +++++++++++++++++++++++------ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index eb748a30..c000ec17 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -271,6 +271,46 @@ describe('toAgent', () => { }); }); + describe('stop() β€” graceful cancellation (F3)', () => { + it('treats an abort-induced onRunFailed as cancellation, not error', async () => { + const source = new StubAgent(); + // Keep the run in flight so stop() races it like a real stream. + let resolveRun!: () => void; + source.runAgent.mockImplementation( + () => new Promise((res) => { + resolveRun = () => res({ result: undefined, newMessages: [] }); + }), + ); + const agent = toAgent(source as never); + + const pending = agent.submit({ message: 'long story' }); + await agent.stop!(); + expect(source.abortRun).toHaveBeenCalledTimes(1); + + // HttpAgent surfaces the abort as a run failure. + source.failRun(new Error('BodyStreamBuffer was aborted')); + resolveRun(); + await pending; + + expect(agent.status()).toBe('idle'); + expect(agent.error()).toBeNull(); + expect(agent.isLoading()).toBe(false); + }); + + it('still surfaces real failures as errors after a previous stop', async () => { + const source = new StubAgent(); + const agent = toAgent(source as never); + + // A stop on an earlier run must not swallow later genuine failures. + await agent.stop!(); + await agent.submit({ message: 'hi' }); // submit resets the abort flag + source.failRun(new Error('boom')); + + expect(agent.status()).toBe('error'); + expect(agent.error()).toBeInstanceOf(Error); + }); + }); + describe('regenerate()', () => { it('truncates messages inclusive of user (userIdx+1) and re-runs without re-appending', async () => { const stub = new StubAgent(); diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index c0392721..68d809fc 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -86,6 +86,34 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag const telemetryProperties = { transport: 'ag-ui' as const, surface: 'to_agent' }; let activeRun: { startedAt: number; errored: boolean } | null = null; + // Set by stop(); lets run-failure handlers distinguish a user-initiated + // abort (graceful cancel) from a genuine stream failure. + let abortRequested = false; + + function isAbortError(error: unknown): boolean { + return error instanceof Error + && (error.name === 'AbortError' || /abort/i.test(error.message)); + } + + /** Settles the store as idle for stop()-induced failures; returns true if handled. */ + function settleIfAborted(error: unknown): boolean { + if (!abortRequested || !isAbortError(error)) return false; + abortRequested = false; + store.status.set('idle'); + store.isLoading.set(false); + // Not a failure: leave store.error null and close out telemetry as a + // normal finish so the aborted run doesn't count as errored. + const run = activeRun; + if (run) { + finishRunTelemetry(run); + // Mark errored so any subsequent finishRunTelemetry/failRunTelemetry + // call on the same run object (e.g. from submit's try block resolving + // after the abort) is a no-op β€” telemetry fires at most once per run. + run.errored = true; + } + return true; + } + // Build the client-tools capability. catalogAsAgUiTools() is used below to // thread the catalog into every runAgent() call. const clientToolsCap = createClientToolsCapability(source, store); @@ -145,6 +173,7 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag reduceEvent(event, store); }, onRunFailed({ error }) { + if (settleIfAborted(error)) return; store.status.set('error'); store.isLoading.set(false); store.error.set(error); @@ -165,6 +194,9 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag clientTools: clientToolsCap, submit: async (input: AgentSubmitInput, _opts?: AgentSubmitOptions) => { + // Reset abort flag so a new submit doesn't swallow genuine failures. + abortRequested = false; + if (input.resume !== undefined) { // Resume path: clear the pending interrupt and replay the run with the // resume payload forwarded to the LangGraph backend via AG-UI's @@ -180,10 +212,12 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag }); finishRunTelemetry(run); } catch (err) { - store.status.set('error'); - store.isLoading.set(false); - store.error.set(err); - failRunTelemetry(err, run); + if (!settleIfAborted(err)) { + store.status.set('error'); + store.isLoading.set(false); + store.error.set(err); + failRunTelemetry(err, run); + } } return; } @@ -205,16 +239,17 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag await source.runAgent(tools.length > 0 ? { tools } : undefined); finishRunTelemetry(run); } catch (err) { - // If the run was aborted via stop(), abortRun() resolves the promise - // rather than rejecting β€” but catch any unexpected errors here. - store.status.set('error'); - store.isLoading.set(false); - store.error.set(err); - failRunTelemetry(err, run); + if (!settleIfAborted(err)) { + store.status.set('error'); + store.isLoading.set(false); + store.error.set(err); + failRunTelemetry(err, run); + } } }, stop: async () => { + abortRequested = true; source.abortRun(); }, From 2edfd315cb256e173413205f3741e3c4f87dd6e1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 23:46:02 -0700 Subject: [PATCH 07/16] perf(chat): track markdown children by index, not identity (F6) Re-parsed markdown produces new child objects every stream delta; identity tracking re-created the DOM subtree per chunk (NG0956). Co-Authored-By: Claude Fable 5 --- libs/chat/src/lib/markdown/markdown-children.component.ts | 8 ++++---- .../src/lib/markdown/views/markdown-table.component.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/chat/src/lib/markdown/markdown-children.component.ts b/libs/chat/src/lib/markdown/markdown-children.component.ts index 3ed3de3b..8809bc57 100644 --- a/libs/chat/src/lib/markdown/markdown-children.component.ts +++ b/libs/chat/src/lib/markdown/markdown-children.component.ts @@ -18,9 +18,9 @@ import { MARKDOWN_VIEW_REGISTRY } from './markdown-view-registry'; * registry. Each child's `type` is looked up in the registry; the resolved * component is rendered with `[node]` bound to that child. * - * Identity-preserving: `track $any(child)` keys on the JS reference of the - * child node. Because @cacheplane/partial-markdown preserves node identity - * across pushes, unchanged subtrees never re-render. + * Position-stable: `track $index` avoids NG0956 re-creation warnings that + * occur when the markdown pipeline re-parses content on every stream delta, + * producing new child object references even for unchanged nodes. */ @Component({ selector: 'chat-md-children', @@ -28,7 +28,7 @@ import { MARKDOWN_VIEW_REGISTRY } from './markdown-view-registry'; imports: [NgComponentOutlet], changeDetection: ChangeDetectionStrategy.OnPush, template: ` - @for (child of children(); track $any(child)) { + @for (child of children(); track $index) { @let comp = resolve(child); @if (comp) { diff --git a/libs/chat/src/lib/markdown/views/markdown-table.component.ts b/libs/chat/src/lib/markdown/views/markdown-table.component.ts index 96f3182a..4da1911a 100644 --- a/libs/chat/src/lib/markdown/views/markdown-table.component.ts +++ b/libs/chat/src/lib/markdown/views/markdown-table.component.ts @@ -17,7 +17,7 @@ import { MarkdownTableRowComponent } from './markdown-table-row.component'; } - @for (row of bodyRows(); track $any(row)) { + @for (row of bodyRows(); track $index) { } From 34ba007d640b511555c07eb01cc657e40fdb146a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 12 Jun 2026 00:42:31 -0700 Subject: [PATCH 08/16] fix(chat): resolve json-render statePath object values to scalars (F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The json-render schema prompt (examples/*/python/src/schemas/json_render.py) documents state-bound props as { statePath: "/path" } with spec.state as the initial value model, but @json-render/core only resolves $state/ $bindState expressions β€” the raw object fell through to the a2ui catalog components and interpolated as the literal string "[object Object]" (KPI Text values, Slider labels, TextField values, Overview metrics). chat-generative-ui now (a) normalizes { statePath: p } props to the engine-native { $bindState: p } plus a _bindings map (mirroring what surfaceToSpec does for A2UI path refs, so user input writes back), and (b) seeds spec.state into the provided store β€” the chat composition's shared store starts empty and render-spec only self-seeds when no store input is given. User-modified paths are never clobbered on spec re-emits. Co-Authored-By: Claude Fable 5 --- .../chat-generative-ui.component.spec.ts | 144 +++++++++++++++++- .../chat-generative-ui.component.ts | 47 +++++- .../normalize-json-render-spec.ts | 75 +++++++++ 3 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 libs/chat/src/lib/primitives/chat-generative-ui/normalize-json-render-spec.ts diff --git a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.spec.ts b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.spec.ts index 86a01b20..a48556e4 100644 --- a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.spec.ts @@ -1,7 +1,12 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { signal, computed } from '@angular/core'; -import type { Spec } from '@json-render/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { Spec, StateStore } from '@json-render/core'; +import { signalStateStore, toRenderRegistry } from '@threadplane/render'; +import { a2uiBasicCatalog } from '../../a2ui/catalog/index'; +import { ChatGenerativeUiComponent } from './chat-generative-ui.component'; +import { normalizeJsonRenderSpec } from './normalize-json-render-spec'; const makeSpec = (root = 'root'): Spec => ({ root, elements: { root: { type: 'div', props: {} } } } as any); @@ -45,3 +50,138 @@ describe('ChatGenerativeUiComponent β€” spec input', () => { expect(loading$()).toBe(true); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// F4: json-render specs use the schema-documented `{ statePath: "/x" }` shape +// for state-bound props (the prompt in the example backends says: +// props: { value: { statePath: "/name" } } +// and `state` carries the initial values). @json-render/core only resolves +// `$state`/`$bindState` expressions, so the raw object fell through to the +// view component and interpolated as the literal string "[object Object]". +// ───────────────────────────────────────────────────────────────────────────── + +/** Spec fixture derived from the documented schema shapes (json_render.py): + * a Text KPI bound via statePath + a Slider whose value/label bind via + * statePath, with `state` carrying the initial value model. */ +const statePathSpec = { + root: 'col', + elements: { + col: { type: 'Column', props: { gap: 'medium' }, children: ['kpi', 'min'] }, + kpi: { type: 'Text', props: { text: { statePath: '/totalRevenue' }, usageHint: 'h3' } }, + min: { + type: 'Slider', + props: { + label: 'Min revenue (USD)', + value: { statePath: '/minRevenue' }, + minValue: 0, + maxValue: 100000, + }, + }, + }, + state: { totalRevenue: '$1.2M', minRevenue: 25000 }, +} as unknown as Spec; + +describe('ChatGenerativeUiComponent β€” statePath resolution (F4)', () => { + let fixture: ComponentFixture; + let store: StateStore; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ChatGenerativeUiComponent] }); + fixture = TestBed.createComponent(ChatGenerativeUiComponent); + // The chat composition passes its own (initially EMPTY) shared store β€” + // replicate that so spec.state seeding is exercised, not render-spec's + // internal store creation. + store = signalStateStore({}); + fixture.componentRef.setInput('registry', toRenderRegistry(a2uiBasicCatalog())); + fixture.componentRef.setInput('store', store); + fixture.componentRef.setInput('spec', statePathSpec); + }); + + function render(): string { + fixture.detectChanges(); + TestBed.flushEffects(); + fixture.detectChanges(); + return (fixture.nativeElement as HTMLElement).textContent ?? ''; + } + + it('never renders "[object Object]" for statePath-bound props', () => { + const text = render(); + expect(text).not.toContain('[object Object]'); + }); + + it('resolves Text.text bound via statePath against spec.state', () => { + const text = render(); + expect(text).toContain('$1.2M'); + }); + + it('resolves Slider value bound via statePath (label shows the number)', () => { + const text = render(); + expect(text).toContain('Min revenue (USD): 25000'); + }); + + it('seeds spec.state into the provided store', () => { + render(); + expect(store.get('/totalRevenue')).toBe('$1.2M'); + expect(store.get('/minRevenue')).toBe(25000); + }); + + it('writes slider input back to the bound state path', () => { + render(); + const slider = (fixture.nativeElement as HTMLElement).querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + expect(slider).toBeTruthy(); + slider.value = '30000'; + slider.dispatchEvent(new Event('input')); + expect(store.get('/minRevenue')).toBe(30000); + }); + + it('does not clobber user-modified state when the spec re-emits', () => { + render(); + store.set('/minRevenue', 42000); + // Re-emit the same spec object graph (streaming re-materializes specs). + fixture.componentRef.setInput('spec', { ...statePathSpec } as Spec); + render(); + expect(store.get('/minRevenue')).toBe(42000); + }); +}); + +describe('normalizeJsonRenderSpec', () => { + it('rewrites { statePath } props to $bindState + _bindings', () => { + const out = normalizeJsonRenderSpec(statePathSpec); + const kpi = out.elements['kpi'] as { props: Record }; + expect(kpi.props['text']).toEqual({ $bindState: '/totalRevenue' }); + const min = out.elements['min'] as { props: Record }; + expect(min.props['value']).toEqual({ $bindState: '/minRevenue' }); + expect(min.props['_bindings']).toEqual({ value: '/minRevenue' }); + }); + + it('leaves scalar props untouched', () => { + const out = normalizeJsonRenderSpec(statePathSpec); + const min = out.elements['min'] as { props: Record }; + expect(min.props['label']).toBe('Min revenue (USD)'); + expect(min.props['minValue']).toBe(0); + }); + + it('returns the same reference when no statePath props exist', () => { + const plain = { + root: 'a', + elements: { a: { type: 'Text', props: { text: 'hello' } } }, + state: {}, + } as unknown as Spec; + expect(normalizeJsonRenderSpec(plain)).toBe(plain); + }); + + it('ignores objects that merely contain a statePath key among others', () => { + const spec = { + root: 'a', + elements: { + a: { type: 'Text', props: { text: { statePath: '/x', other: 1 } } }, + }, + state: {}, + } as unknown as Spec; + const out = normalizeJsonRenderSpec(spec); + const a = out.elements['a'] as { props: Record }; + expect(a.props['text']).toEqual({ statePath: '/x', other: 1 }); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts index 59b9fc1b..5ed59074 100644 --- a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts +++ b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: MIT import { Component, + computed, + effect, input, output, ChangeDetectionStrategy, @@ -11,6 +13,7 @@ import type { AngularRegistry, RenderEvent } from '@threadplane/render'; import { RenderSpecComponent } from '@threadplane/render'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_GENERATIVE_UI_STYLES } from '../../styles/chat-generative-ui.styles'; +import { normalizeJsonRenderSpec } from './normalize-json-render-spec'; @Component({ selector: 'chat-generative-ui', @@ -19,9 +22,9 @@ import { CHAT_GENERATIVE_UI_STYLES } from '../../styles/chat-generative-ui.style changeDetection: ChangeDetectionStrategy.OnPush, styles: [CHAT_HOST_TOKENS, CHAT_GENERATIVE_UI_STYLES], template: ` - @if (spec()) { + @if (normalizedSpec()) { ) => unknown | Promise> | undefined>(undefined); readonly loading = input(false); readonly events = output(); + + /** The bound spec with schema-documented `{ statePath }` prop refs + * rewritten to engine-native `{ $bindState }` + `_bindings` so values + * resolve against the state store instead of interpolating as + * "[object Object]" (F4). */ + protected readonly normalizedSpec = computed(() => { + const s = this.spec(); + return s ? normalizeJsonRenderSpec(s) : null; + }); + + /** Last value this component seeded per state path. Lets the seeding + * effect distinguish "still the value we wrote (possibly a partial + * chunk from streaming β€” safe to overwrite with the newer one)" from + * "user edited it via a bound control β€” leave it alone". */ + private readonly seeded = new Map(); + + constructor() { + // Seed `spec.state` (the schema's "initial state model") into the + // store. When the chat composition supplies its shared store, it is + // initially EMPTY β€” render-spec only seeds spec.state into its own + // internal store when no store input is given β€” so without this, + // statePath/$bindState props would resolve to undefined. + effect(() => { + const s = this.spec(); + const store = this.store(); + const state = s?.state as Record | undefined; + if (!state || !store) return; + for (const [key, value] of Object.entries(state)) { + const path = key.startsWith('/') ? key : `/${key}`; + const current = store.get(path); + const untouched = + current === undefined || + (this.seeded.has(path) && current === this.seeded.get(path)); + if (untouched) { + if (current !== value) store.set(path, value); + this.seeded.set(path, value); + } + } + }); + } } diff --git a/libs/chat/src/lib/primitives/chat-generative-ui/normalize-json-render-spec.ts b/libs/chat/src/lib/primitives/chat-generative-ui/normalize-json-render-spec.ts new file mode 100644 index 00000000..d179f640 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-generative-ui/normalize-json-render-spec.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +import type { Spec, UIElement } from '@json-render/core'; + +/** + * The json-render schema prompt shipped with the example backends documents + * state-bound props as `{ statePath: "/path" }` (the A2UI binding dialect β€” + * see `examples//python/src/schemas/json_render.py`). The + * @json-render/core + * prop resolver only understands `$state`/`$bindState` expressions, so a raw + * `{ statePath }` object would fall through to the view component unresolved + * and interpolate as the literal string "[object Object]". + * + * `normalizeJsonRenderSpec` rewrites each top-level `{ statePath: p }` prop + * to the engine-native `{ $bindState: p }` AND records it in the element's + * `_bindings` map (prop name β†’ path) so the a2ui catalog components can + * write user input back to the state store β€” mirroring exactly what + * `surfaceToSpec` does for A2UI path refs. + */ +interface StatePathRef { + statePath: string; +} + +function isStatePathRef(value: unknown): value is StatePathRef { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record)['statePath'] === 'string' && + Object.keys(value).length === 1 + ); +} + +/** Rewrites schema-documented `{ statePath }` prop refs to `$bindState` + + * `_bindings`. Returns the input reference unchanged when no rewriting is + * needed (keeps downstream memoization intact). */ +export function normalizeJsonRenderSpec(spec: Spec): Spec { + const sourceElements = (spec.elements ?? {}) as Record; + let specChanged = false; + const elements: Record = {}; + + for (const [id, el] of Object.entries(sourceElements)) { + const rawProps = el?.props as Record | undefined; + if (!rawProps) { + elements[id] = el; + continue; + } + + let elementChanged = false; + const props: Record = {}; + const bindings: Record = {}; + + for (const [key, value] of Object.entries(rawProps)) { + if (isStatePathRef(value)) { + props[key] = { $bindState: value.statePath }; + bindings[key] = value.statePath; + elementChanged = true; + } else { + props[key] = value; + } + } + + if (elementChanged) { + // Merge with any model-emitted `_bindings` (never observed, but cheap + // to preserve) β€” rewritten paths win. + const existing = (rawProps['_bindings'] ?? {}) as Record; + props['_bindings'] = { ...existing, ...bindings }; + elements[id] = { ...el, props } as UIElement; + specChanged = true; + } else { + elements[id] = el; + } + } + + return specChanged ? ({ ...spec, elements } as Spec) : spec; +} From 9f8b6b4d4d4c633b187b19e4e580ce46966bff87 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 12 Jun 2026 01:50:29 -0700 Subject: [PATCH 09/16] fix(chat): isolate json-render stores per surface + seed-effect tracking (review) Passing the conversation-wide internal store into every generative-ui surface made same-key dashboards collide across messages (regenerate, multi-dashboard, thread switch). Match a2ui isolation: only an explicit consumer store is shared; otherwise render-spec self-seeds per instance. Co-Authored-By: Claude Fable 5 --- .../lib/compositions/chat/chat.component.ts | 8 +- .../chat-generative-ui.component.spec.ts | 107 +++++++++++++++++- .../chat-generative-ui.component.ts | 40 ++++--- 3 files changed, 136 insertions(+), 19 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 4751c126..0ab7f2cf 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -223,10 +223,16 @@ export function isPinned( } @if (classified.spec(); as spec) { + { beforeEach(() => { TestBed.configureTestingModule({ imports: [ChatGenerativeUiComponent] }); fixture = TestBed.createComponent(ChatGenerativeUiComponent); - // The chat composition passes its own (initially EMPTY) shared store β€” - // replicate that so spec.state seeding is exercised, not render-spec's - // internal store creation. + // An EXPLICIT consumer-provided store (initially EMPTY) β€” replicate that + // so spec.state seeding is exercised, not render-spec's internal store + // creation. (The chat composition itself no longer passes its internal + // store; only a consumer-supplied store reaches this input.) store = signalStateStore({}); fixture.componentRef.setInput('registry', toRenderRegistry(a2uiBasicCatalog())); fixture.componentRef.setInput('store', store); @@ -144,6 +145,106 @@ describe('ChatGenerativeUiComponent β€” statePath resolution (F4)', () => { render(); expect(store.get('/minRevenue')).toBe(42000); }); + + it('overwrites component-seeded partial chunk values when fuller state streams in', () => { + // First emission carries a partial streaming chunk of the value. + fixture.componentRef.setInput('spec', { + ...statePathSpec, + state: { totalRevenue: '$1.', minRevenue: 25000 }, + } as unknown as Spec); + render(); + expect(store.get('/totalRevenue')).toBe('$1.'); + + // Re-emit with the fuller value β€” the component wrote '$1.' itself, so + // it is safe to overwrite (only USER edits are preserved). + fixture.componentRef.setInput('spec', { ...statePathSpec } as Spec); + const text = render(); + expect(store.get('/totalRevenue')).toBe('$1.2M'); + expect(text).toContain('$1.2M'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Store isolation vs. sharing across component instances. +// +// Without a consumer store, each must be fully isolated: +// render-spec self-seeds a per-instance internal store from spec.state, so two +// dashboards with overlapping state keys (e.g. /totalRevenue on every message +// of a conversation) never collide. This is the regression test for the bug +// where the chat composition's conversation-wide internal store was passed to +// every surface. An EXPLICIT consumer store, by contrast, intentionally has +// shared/live semantics across all surfaces bound to it. +// ───────────────────────────────────────────────────────────────────────────── + +/** Spec like statePathSpec but with a different value model for /totalRevenue. */ +const otherStatePathSpec = { + ...statePathSpec, + state: { totalRevenue: '$9.9M', minRevenue: 75000 }, +} as unknown as Spec; + +describe('ChatGenerativeUiComponent β€” store isolation across instances', () => { + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ChatGenerativeUiComponent] }); + }); + + function createInstance(spec: Spec, store?: StateStore): ComponentFixture { + const fixture = TestBed.createComponent(ChatGenerativeUiComponent); + fixture.componentRef.setInput('registry', toRenderRegistry(a2uiBasicCatalog())); + if (store) fixture.componentRef.setInput('store', store); + fixture.componentRef.setInput('spec', spec); + return fixture; + } + + function render(fixture: ComponentFixture): string { + fixture.detectChanges(); + TestBed.flushEffects(); + fixture.detectChanges(); + return (fixture.nativeElement as HTMLElement).textContent ?? ''; + } + + it('isolates instances WITHOUT a consumer store β€” overlapping paths never collide', () => { + const a = createInstance(statePathSpec); + const b = createInstance(otherStatePathSpec); + const textA = render(a); + const textB = render(b); + + // Each instance renders ITS OWN spec.state values for the same paths. + expect(textA).toContain('$1.2M'); + expect(textA).not.toContain('$9.9M'); + expect(textB).toContain('$9.9M'); + expect(textB).not.toContain('$1.2M'); + + // A user edit inside one surface must not leak into the other. + const sliderA = (a.nativeElement as HTMLElement).querySelector( + 'input[type="range"]', + ) as HTMLInputElement; + sliderA.value = '30000'; + sliderA.dispatchEvent(new Event('input')); + render(a); + expect(render(b)).toContain('Min revenue (USD): 75000'); + }); + + it('shares one EXPLICIT consumer store across instances β€” first seeder wins, values are live', () => { + const shared = signalStateStore({}); + const a = createInstance(statePathSpec, shared); + const textA = render(a); + expect(textA).toContain('$1.2M'); + + // Second instance binds the SAME store with different spec.state for the + // same paths. The paths are already populated (and not by THIS instance), + // so it must not clobber them: both surfaces show the shared values. + const b = createInstance(otherStatePathSpec, shared); + const textB = render(b); + expect(shared.get('/totalRevenue')).toBe('$1.2M'); + expect(shared.get('/minRevenue')).toBe(25000); + expect(textB).toContain('$1.2M'); + expect(textB).not.toContain('$9.9M'); + + // Live semantics: a write to the shared store is reflected in BOTH. + shared.set('/totalRevenue', '$3.0M'); + expect(render(a)).toContain('$3.0M'); + expect(render(b)).toContain('$3.0M'); + }); }); describe('normalizeJsonRenderSpec', () => { diff --git a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts index 5ed59074..26be378c 100644 --- a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts +++ b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts @@ -6,6 +6,7 @@ import { effect, input, output, + untracked, ChangeDetectionStrategy, } from '@angular/core'; import type { Spec, StateStore } from '@json-render/core'; @@ -58,27 +59,36 @@ export class ChatGenerativeUiComponent { private readonly seeded = new Map(); constructor() { - // Seed `spec.state` (the schema's "initial state model") into the - // store. When the chat composition supplies its shared store, it is - // initially EMPTY β€” render-spec only seeds spec.state into its own - // internal store when no store input is given β€” so without this, - // statePath/$bindState props would resolve to undefined. + // Seed `spec.state` (the schema's "initial state model") into an + // EXPLICIT consumer-provided store, which is typically EMPTY at first β€” + // without this, statePath/$bindState props would resolve to undefined. + // A consumer-provided store intentionally has shared/live semantics + // across surfaces: every surface bound to it reads (and writes) the same + // state, so the first surface to seed a path wins. When NO store input + // is given, this effect is a no-op and render-spec self-seeds its own + // per-instance internal store from spec.state, keeping surfaces with + // overlapping state keys isolated from each other (a2ui parity). effect(() => { const s = this.spec(); const store = this.store(); const state = s?.state as Record | undefined; if (!state || !store) return; - for (const [key, value] of Object.entries(state)) { - const path = key.startsWith('/') ? key : `/${key}`; - const current = store.get(path); - const untouched = - current === undefined || - (this.seeded.has(path) && current === this.seeded.get(path)); - if (untouched) { - if (current !== value) store.set(path, value); - this.seeded.set(path, value); + // Untracked: store reads/writes must not become dependencies β€” the + // effect re-runs on spec/store-identity changes only, not on every + // write to the (possibly shared) store. + untracked(() => { + for (const [key, value] of Object.entries(state)) { + const path = key.startsWith('/') ? key : `/${key}`; + const current = store.get(path); + const untouched = + current === undefined || + (this.seeded.has(path) && current === this.seeded.get(path)); + if (untouched) { + if (current !== value) store.set(path, value); + this.seeded.set(path, value); + } } - } + }); }); } } From bab0bc3649abff4c27676ff01a6b227f62af47a2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 12 Jun 2026 02:53:16 -0700 Subject: [PATCH 10/16] fix(cockpit): pass explicit stores to json-render dashboards + pin composition isolation (review) The store-isolation fix means backend-state sync reaches json-render surfaces only via an explicit consumer store; the two cockpit dashboard capabilities want exactly those shared live semantics, so they now opt in. Adds a composition-level test pinning the isolation binding. Co-Authored-By: Claude Fable 5 --- .../angular/src/app/json-render.component.ts | 8 +- .../ag-ui/json-render/python/docs/guide.md | 14 ++- cockpit/ag-ui/json-render/python/src/graph.py | 5 +- .../src/app/generative-ui.component.ts | 9 +- .../compositions/chat/chat.component.spec.ts | 111 +++++++++++++++++- 5 files changed, 137 insertions(+), 10 deletions(-) diff --git a/cockpit/ag-ui/json-render/angular/src/app/json-render.component.ts b/cockpit/ag-ui/json-render/angular/src/app/json-render.component.ts index df95311e..cf5a669a 100644 --- a/cockpit/ag-ui/json-render/angular/src/app/json-render.component.ts +++ b/cockpit/ag-ui/json-render/angular/src/app/json-render.component.ts @@ -2,6 +2,7 @@ import { Component } from '@angular/core'; import { ChatComponent, ChatWelcomeSuggestionComponent, views } from '@threadplane/chat'; import { injectAgent } from '@threadplane/ag-ui'; +import { signalStateStore } from '@threadplane/render'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { StatCardComponent } from './views/stat-card.component'; import { ContainerComponent } from './views/container.component'; @@ -30,7 +31,7 @@ const WELCOME_SUGGESTIONS = [ imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], template: ` - +
@for (s of suggestions; track s.value) { @@ -44,5 +45,10 @@ export class JsonRenderComponent { protected readonly agent = injectAgent(); protected readonly dashboardViews = dashboardViews; protected readonly suggestions = WELCOME_SUGGESTIONS; + /** + * Explicit shared store: backend state (STATE_SNAPSHOT) syncs into it via + * the chat composition, so every dashboard surface reads live values. + */ + protected readonly dashStore = signalStateStore({}); protected send(text: string): void { void this.agent.submit({ message: text }); } } diff --git a/cockpit/ag-ui/json-render/python/docs/guide.md b/cockpit/ag-ui/json-render/python/docs/guide.md index e320fc49..cdc4d89f 100644 --- a/cockpit/ag-ui/json-render/python/docs/guide.md +++ b/cockpit/ag-ui/json-render/python/docs/guide.md @@ -10,18 +10,19 @@ as the backend updates the data. -Render a backend-authored dashboard with `@threadplane/chat` over the AG-UI adapter. Register your view components in the `views` map and pass it to ``. Have the agent emit a json-render spec (with `$state` bindings) as the assistant message content, and put the data the spec binds to in the LangGraph graph state so `ag-ui-langgraph` emits it as a `STATE_SNAPSHOT`. The chat composition resolves the bindings automatically. +Render a backend-authored dashboard with `@threadplane/chat` over the AG-UI adapter. Register your view components in the `views` map and pass it to ``, along with an explicit `[store]` β€” the composition syncs incoming agent state into that store, and the spec's `$state` bindings resolve against it. Have the agent emit a json-render spec (with `$state` bindings) as the assistant message content, and put the data the spec binds to in the LangGraph graph state so `ag-ui-langgraph` emits it as a `STATE_SNAPSHOT`. -Build a `views` registry keyed by the component types your spec will reference, and pass it to ``: +Build a `views` registry keyed by the component types your spec will reference, and pass it to `` together with an explicit store. Without a `[store]`, each render surface seeds its own isolated store from the spec β€” the explicit store is what lets backend state (`STATE_SNAPSHOT`) reach the dashboard bindings: ```typescript // json-render.component.ts import { ChatComponent, views } from '@threadplane/chat'; import { injectAgent } from '@threadplane/ag-ui'; +import { signalStateStore } from '@threadplane/render'; import { StatCardComponent } from './views/stat-card.component'; import { DashboardGridComponent } from './views/dashboard-grid.component'; // …line-chart, bar-chart, data-grid, container @@ -31,10 +32,13 @@ const dashboardViews = views({ dashboard_grid: DashboardGridComponent, // … }); + +// In the component class: +readonly dashStore = signalStateStore({}); ``` ```html - + ``` @@ -76,8 +80,8 @@ data prop uses a `$state` binding rather than a literal: This is the AG-UI-native part. Instead of pushing data through a side channel, put it in the **graph state** β€” `ag-ui-langgraph` emits the state object as a `STATE_SNAPSHOT`, the adapter writes it to the agent's `state` signal, and the -chat composition syncs it into the render store where the `$state` bindings -resolve: +chat composition syncs it into the explicit `[store]` you passed, where the +`$state` bindings resolve: ```python # graph.py β€” emit_state returns the accumulated tool data into state diff --git a/cockpit/ag-ui/json-render/python/src/graph.py b/cockpit/ag-ui/json-render/python/src/graph.py index 2f19c184..8c3c453e 100644 --- a/cockpit/ag-ui/json-render/python/src/graph.py +++ b/cockpit/ag-ui/json-render/python/src/graph.py @@ -17,8 +17,9 @@ emit_state walks the message history for this turn and returns the tool results as top-level state fields β€” ag-ui-langgraph emits them as -STATE_SNAPSHOT; the Angular chat-lib effect syncs them into the render -store, where the spec's $state bindings resolve them. +STATE_SNAPSHOT; the Angular chat-lib effect syncs them into the explicit +[store] the app passes to , where the spec's $state bindings +resolve them. """ import json diff --git a/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts b/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts index d627999d..248592bb 100644 --- a/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts +++ b/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts @@ -2,6 +2,7 @@ import { Component } from '@angular/core'; import { ChatComponent, ChatWelcomeSuggestionComponent, views } from '@threadplane/chat'; import { injectAgent } from '@threadplane/langgraph'; +import { signalStateStore } from '@threadplane/render'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { StatCardComponent } from './views/stat-card.component'; @@ -31,7 +32,7 @@ const WELCOME_SUGGESTIONS = [ imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], template: ` - +
@for (s of suggestions; track s.value) { { @@ -493,3 +496,109 @@ describe('ChatComponent β€” no bubble-level GenUI skeleton', () => { expect(src.includes('ChatGenuiSkeletonComponent')).toBe(false); }); }); + +describe('ChatComponent β€” json-render surface store binding (composition isolation pin)', () => { + // Pins the one-line template binding on : + // + // [store]="store()" + // + // The composition must hand each json-render surface ONLY the explicit + // consumer store β€” never resolvedStore()'s conversation-wide internal + // fallback. Full ChatComponent template rendering is not feasible under + // vitest JIT (see the left-flash notes above), so we pin the binding by + // extracting the LIVE expression from the template source, evaluating it on + // a real ChatComponent instance (exactly the value the template passes to + // every surface), and mounting two real surfaces whose + // specs carry an OVERLAPPING state key with different values. + // + // Fails-on-revert reasoning: with the correct binding, no consumer store is + // set, so the evaluated expression is undefined and each surface self-seeds + // a per-instance store β†’ isolation. If the binding is reverted to + // resolvedStore(), both surfaces receive the SAME internal store (views are + // bound, so the fallback is active): the first surface seeds /totalRevenue + // and the second surface β€” which never seeded that path itself β€” must not + // clobber it, so it renders the FIRST message's value and the assertions + // below go red. + + const contentA = JSON.stringify({ + root: 'kpi', + elements: { kpi: { type: 'Text', props: { text: { statePath: '/totalRevenue' } } } }, + state: { totalRevenue: '$1.2M' }, + }); + const contentB = JSON.stringify({ + root: 'kpi', + elements: { kpi: { type: 'Text', props: { text: { statePath: '/totalRevenue' } } } }, + state: { totalRevenue: '$9.9M' }, + }); + + /** Extract the `[store]` binding expression from the `` + * element in the composition template (e.g. `store` or `resolvedStore`). */ + function extractSurfaceStoreBinding(src: string): string { + const start = src.indexOf('', start)); + const match = /\[store\]="([A-Za-z_$][\w$]*)\(\)"/.exec(element); + if (!match) throw new Error('no [store]="fn()" binding found on '); + return match[1]; + } + + it('two spec messages with overlapping state keys render their OWN values (isolation)', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const url = await import('node:url'); + const here = path.dirname(url.fileURLToPath(import.meta.url)); + const src = fs.readFileSync(path.join(here, 'chat.component.ts'), 'utf8'); + const bindingFn = extractSurfaceStoreBinding(src); + + TestBed.configureTestingModule({ imports: [ChatGenerativeUiComponent] }); + const injector = TestBed.inject(Injector); + + // Classify both assistant message contents exactly as the template does. + const classifierA = createContentClassifier(); + classifierA.update(contentA); + const classifierB = createContentClassifier(); + classifierB.update(contentB); + expect(classifierA.type()).toBe('json-render'); + expect(classifierB.type()).toBe('json-render'); + const specA = classifierA.spec() as Spec; + const specB = classifierB.spec() as Spec; + expect(specA).toBeTruthy(); + expect(specB).toBeTruthy(); + + // Real composition instance carrying the two assistant spec messages, + // with [views] bound so the internal-store fallback would be ACTIVE if + // the surface binding were ever pointed back at resolvedStore(). + let surfaceStore: StateStore | undefined; + runInInjectionContext(injector, () => { + const comp = new ChatComponent(); + setSignalInput(comp.agent, mockAgent({ + messages: [new AIMessage(contentA), new AIMessage(contentB)], + })); + setSignalInput(comp.views, views({})); + // Sanity: the internal fallback IS available β€” the isolation asserted + // below must come from the binding choice, not from a missing store. + expect(comp.resolvedStore()).toBeTruthy(); + // Evaluate the template's actual binding expression for the surfaces. + surfaceStore = (comp as unknown as Record StateStore | undefined>)[bindingFn](); + }); + + function mountSurface(spec: Spec): string { + const fixture = TestBed.createComponent(ChatGenerativeUiComponent); + fixture.componentRef.setInput('registry', toRenderRegistry(a2uiBasicCatalog())); + fixture.componentRef.setInput('store', surfaceStore); + fixture.componentRef.setInput('spec', spec); + fixture.detectChanges(); + TestBed.flushEffects(); + fixture.detectChanges(); + return (fixture.nativeElement as HTMLElement).textContent ?? ''; + } + + const textA = mountSurface(specA); + const textB = mountSurface(specB); + + expect(textA).toContain('$1.2M'); + expect(textA).not.toContain('$9.9M'); + expect(textB).toContain('$9.9M'); + expect(textB).not.toContain('$1.2M'); + }); +}); From 76f1b449ef448f3b6ef729fbf17469082a0f775a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 12 Jun 2026 03:24:00 -0700 Subject: [PATCH 11/16] chore(chat): drop unused @angular/forms peer dependency chat-input no longer uses ngModel (F1); dependency-checks lint flagged the stale peer. Consumer-visible: @threadplane/chat no longer requires @angular/forms. Co-Authored-By: Claude Fable 5 --- libs/chat/CHANGELOG.md | 1 + libs/chat/package.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/chat/CHANGELOG.md b/libs/chat/CHANGELOG.md index 45011375..ff566b4b 100644 --- a/libs/chat/CHANGELOG.md +++ b/libs/chat/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changed +- **`@angular/forms` peer dependency removed:** `chat-input` now binds its textarea with a direct `[value]`/`(input)` pair (fixes the composer keeping sent text under zoneless + OnPush). `@threadplane/chat` no longer requires `@angular/forms` β€” consumers may drop it unless they use it themselves. - **Public API trim:** `@threadplane/chat` no longer re-exports `provideViews` / `VIEW_REGISTRY` from `@threadplane/render`. Consumers using `` / `` directly should import from `@threadplane/render`. For chat's markdown view overrides, provide `MARKDOWN_VIEW_REGISTRY` directly using `overrideViews(cacheplaneMarkdownViews, { … })` from `@threadplane/render` β€” the previously-documented `provideViews(withViews(…))` pattern never actually drove rendering. - **License:** `@threadplane/chat` is dual-licensed under PolyForm Noncommercial 1.0.0 (free noncommercial use) or a Threadplane Commercial license (production use inside a for-profit context). diff --git a/libs/chat/package.json b/libs/chat/package.json index 52a4e45b..2ee4a78d 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -16,7 +16,6 @@ "zod": "^3.25.0", "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", - "@angular/forms": "^20.0.0 || ^21.0.0", "@angular/platform-browser": "^20.0.0 || ^21.0.0", "@threadplane/licensing": "*", "@threadplane/render": "*", From 777d9ba2d6d13c2226632eddd88e3797d6977a57 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 12 Jun 2026 03:56:06 -0700 Subject: [PATCH 12/16] fix(ag-ui): also settle abort-shaped RUN_ERROR events on stop (F3) The AG-UI client surfaces a user abort through the event stream as RUN_ERROR in addition to onRunFailed; the event path bypassed the graceful-stop guard, so the error banner still appeared in live smoke. Introduces an abortSettled flag alongside abortRequested: the first delivery (event or onRunFailed) settles the store as idle and sets abortSettled; any subsequent delivery for the same abort is swallowed by settleIfAborted() without re-touching the store. Both flags are reset together at the top of submit() so the next run starts clean and genuine failures after a previous stop are never suppressed. Co-Authored-By: Claude Fable 5 --- libs/ag-ui/src/lib/to-agent.spec.ts | 50 +++++++++++++++++++++++++++++ libs/ag-ui/src/lib/to-agent.ts | 24 +++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index c000ec17..e02ddd85 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -297,6 +297,56 @@ describe('toAgent', () => { expect(agent.isLoading()).toBe(false); }); + it('treats an abort-shaped RUN_ERROR event as cancellation, not error', async () => { + const source = new StubAgent(); + let resolveRun!: () => void; + source.runAgent.mockImplementation( + () => new Promise((res) => { + resolveRun = () => res({ result: undefined, newMessages: [] }); + }), + ); + const agent = toAgent(source as never); + + const pending = agent.submit({ message: 'long story' }); + await agent.stop!(); + + // The real HttpAgent also surfaces the abort as a RUN_ERROR event + // through the event stream (not just onRunFailed). + source.emit({ type: 'RUN_ERROR', message: 'BodyStreamBuffer was aborted' } as never); + resolveRun(); + await pending; + + expect(agent.status()).toBe('idle'); + expect(agent.error()).toBeNull(); + expect(agent.isLoading()).toBe(false); + }); + + it('handles duplicate abort delivery (RUN_ERROR event THEN onRunFailed) gracefully', async () => { + const source = new StubAgent(); + let resolveRun!: () => void; + source.runAgent.mockImplementation( + () => new Promise((res) => { + resolveRun = () => res({ result: undefined, newMessages: [] }); + }), + ); + const agent = toAgent(source as never); + + const pending = agent.submit({ message: 'long story' }); + await agent.stop!(); + + // First delivery: via the event stream + source.emit({ type: 'RUN_ERROR', message: 'BodyStreamBuffer was aborted' } as never); + // Second delivery: via onRunFailed (same abort) + source.failRun(new Error('BodyStreamBuffer was aborted')); + resolveRun(); + await pending; + + // Duplicate delivery must NOT flip status back to error + expect(agent.status()).toBe('idle'); + expect(agent.error()).toBeNull(); + expect(agent.isLoading()).toBe(false); + }); + it('still surfaces real failures as errors after a previous stop', async () => { const source = new StubAgent(); const agent = toAgent(source as never); diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index 68d809fc..86ee8e6a 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -90,6 +90,14 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag // abort (graceful cancel) from a genuine stream failure. let abortRequested = false; + // Set to true the first time settleIfAborted() handles an abort error for the + // current run. The AG-UI client can surface the same abort via both the event + // stream (RUN_ERROR event) AND onRunFailed β€” abortSettled lets the second + // delivery see through as a no-op rather than re-writing store state or + // triggering a real error path. Both flags are reset together at the top of + // submit() so the next run starts clean. + let abortSettled = false; + function isAbortError(error: unknown): boolean { return error instanceof Error && (error.name === 'AbortError' || /abort/i.test(error.message)); @@ -97,8 +105,13 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag /** Settles the store as idle for stop()-induced failures; returns true if handled. */ function settleIfAborted(error: unknown): boolean { + // If we already settled this abort (duplicate delivery β€” e.g. RUN_ERROR + // event THEN onRunFailed), just swallow without touching the store again. + if (abortSettled && isAbortError(error)) return true; + if (!abortRequested || !isAbortError(error)) return false; abortRequested = false; + abortSettled = true; store.status.set('idle'); store.isLoading.set(false); // Not a failure: leave store.error null and close out telemetry as a @@ -170,6 +183,13 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag // This subscription lives for the lifetime of `source`. source.subscribe({ onEvent({ event }) { + // The AG-UI client surfaces a user-initiated abort both as a + // RUN_ERROR event (here) and via onRunFailed; guard the event path too + // so the reducer never marks a deliberate stop as an error. + if (event.type === 'RUN_ERROR') { + const message = (event as { message?: string }).message ?? ''; + if (settleIfAborted(new Error(message))) return; + } reduceEvent(event, store); }, onRunFailed({ error }) { @@ -194,8 +214,10 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag clientTools: clientToolsCap, submit: async (input: AgentSubmitInput, _opts?: AgentSubmitOptions) => { - // Reset abort flag so a new submit doesn't swallow genuine failures. + // Reset both abort flags so a new submit starts clean and genuine + // failures after a previous stop are never swallowed. abortRequested = false; + abortSettled = false; if (input.resume !== undefined) { // Resume path: clear the pending interrupt and replay the run with the From 207726fe795787f0d31916df8f56606af37ecb46 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 12 Jun 2026 04:13:27 -0700 Subject: [PATCH 13/16] =?UTF-8?q?docs(ag-ui):=20Phase=203=20closure=20stat?= =?UTF-8?q?us=20=E2=80=94=20F1-F4=20+=20main=20F6=20closed,=20residuals=20?= =?UTF-8?q?logged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-06-11-ag-ui-capability-findings.md | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md b/docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md index 9469c8a4..e4cd13b0 100644 --- a/docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md +++ b/docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md @@ -47,11 +47,17 @@ The research delegation runs fine but renders as a generic tool row; the canonic ### F6 β€” NG0956 console warnings during streaming β€” chat lib perf (low) Angular warns repeatedly that a tracked `@for` collection (size 1) is re-created per stream chunk (track-by-identity). Cheap fix: stable `track` keys in the streaming message list templates. -## Proposed gap-closure order (Phase 3+) - -1. **P3a β€” F2** example CSS (small, no lib surface). -2. **P3b β€” F1** chat-input clear-on-send (lib, TDD; affects every consumer). -3. **P3c β€” F3** ag-ui adapter graceful stop (lib, TDD; parity). -4. **P3d β€” F4** json-render value binding (shared; fixes canonical prod too). -5. **P3e β€” F5** subagent card over AG-UI (adapter design + impl). -6. **P3f β€” F6** track-by keys (cleanup). +## Gap-closure status (updated 2026-06-12, Phase 3) + +Phase 3 (branch `ag-ui-gap-closure-p3`, plan `2026-06-11-ag-ui-gap-closure-p3.md`) closed F1, F2, F3, F4, and the main F6 source β€” each TDD'd, two-stage reviewed, and re-verified in a live Chrome smoke against the real backend: + +- **F1 βœ…** β€” chat-input binds `[value]`/`(input)` directly (FormsModule dropped, `@angular/forms` peer removed from `@threadplane/chat`); composer verified clearing live, including mid-stream sends. The `name="messageText"` attribute was preserved for cockpit selectors. +- **F2 βœ…** β€” AgUiShell sets `data-color-scheme` + index.html pre-bootstrap script; page chrome (itinerary panel, mode hosts) verified light live. +- **F3 βœ…** β€” adapter settles abort-shaped failures as graceful cancellation on BOTH delivery paths (`onRunFailed` AND the synthesized `RUN_ERROR` event β€” the second path was caught only by the live smoke, not the stub harness). No banner on stop, verified live. +- **F4 βœ…** β€” json-render `{statePath}` props are normalized to the `$bindState` dialect + `_bindings` in `chat-generative-ui`; surfaces are store-isolated per instance unless a consumer passes an explicit `[store]` (the two cockpit dashboard capabilities now opt in explicitly β€” backend STATE_SNAPSHOT state requires an explicit store by design, matching a2ui). Live dashboard renders real values; zero `[object Object]`. +- **F6 βœ… (main source)** β€” markdown children/table rows now track by `$index`; zero NG0956 during text/reasoning streaming. **Residual:** a handful of NG0956 warnings still fire during json-render *spec assembly* streaming; the source is not any identity-tracked `@for` in libs/chat, libs/render, or the example (all audited) β€” follow-up to pinpoint (likely inside the spec re-materialization path). +- **F5 ⏳** β€” subagent card over AG-UI deferred to Phase 4 (needs design: mapping graph subagent custom events into the chat subagent contract in `toAgent()`). + +Additional follow-ups logged during Phase 3: +- Icon rendering: the a2ui catalog icon component renders icon *names* as text (`trending_up`) β€” proper icon support is a new catalog feature, not built. +- json-render normalizer handles top-level `{statePath}` props only (matches the documented schema); nested occurrences from model drift would still stringify. From 8eac8b21f2d2c55da62718cdd4eee9f359de346f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 12 Jun 2026 04:46:08 -0700 Subject: [PATCH 14/16] fix(ag-ui): reset abort flags in regenerate + settle duplicate aborts defensively (review) stop -> regenerate -> stop left the store wedged in streaming because regenerate never reset the abort flags and the duplicate-delivery guard returned without settling. Also documents the json-render store-isolation change in the chat CHANGELOG and fixes the README peer table. Co-Authored-By: Claude Fable 5 --- libs/ag-ui/src/lib/to-agent.spec.ts | 60 +++++++++++++++++++++++++++++ libs/ag-ui/src/lib/to-agent.ts | 27 ++++++++++--- libs/chat/CHANGELOG.md | 1 + libs/chat/README.md | 1 - 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index e02ddd85..6021d0ca 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -359,6 +359,66 @@ describe('toAgent', () => { expect(agent.status()).toBe('error'); expect(agent.error()).toBeInstanceOf(Error); }); + + it('stop β†’ regenerate β†’ stop does NOT wedge the store in streaming', async () => { + const source = new StubAgent(); + + // Run A: make runAgent hang so we can stop it mid-flight. + let resolveRunA!: () => void; + source.runAgent.mockImplementationOnce( + () => new Promise((res) => { + resolveRunA = () => res({ result: undefined, newMessages: [] }); + }), + ); + const agent = toAgent(source as never); + + // Submit run A and emit a complete assistant message before stop. + const pendingA = agent.submit({ message: 'first question' }); + source.emit({ type: 'RUN_STARTED' } as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-1', role: 'assistant' } as unknown as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-1', delta: 'reply' } as unknown as BaseEvent); + source.emit({ type: 'TEXT_MESSAGE_END', messageId: 'ai-1' } as unknown as BaseEvent); + source.emit({ type: 'RUN_FINISHED' } as BaseEvent); + + // Stop run A (abortSettled becomes true after this). + await agent.stop!(); + source.failRun(new Error('BodyStreamBuffer was aborted')); + resolveRunA(); + await pendingA; + + // Run A settled: idle, no error. + expect(agent.status()).toBe('idle'); + expect(agent.error()).toBeNull(); + + // There must be an assistant message at index 1 (user[0], assistant[1]). + expect(agent.messages()).toHaveLength(2); + expect(agent.messages()[1].role).toBe('assistant'); + + // Run B (regenerate): hang so we can stop it too. + let resolveRunB!: () => void; + source.runAgent.mockImplementationOnce( + () => new Promise((res) => { + resolveRunB = () => res({ result: undefined, newMessages: [] }); + }), + ); + + const pendingB = agent.regenerate(1); + + // Simulate the regeneration run starting (status β†’ running, isLoading β†’ true). + source.emit({ type: 'RUN_STARTED' } as BaseEvent); + expect(agent.isLoading()).toBe(true); + + // Stop the regeneration mid-flight. + await agent.stop!(); + source.failRun(new Error('BodyStreamBuffer was aborted')); + resolveRunB(); + await pendingB; + + // CRITICAL: must NOT be wedged in streaming/running/isLoading. + expect(agent.status()).toBe('idle'); + expect(agent.error()).toBeNull(); + expect(agent.isLoading()).toBe(false); + }); }); describe('regenerate()', () => { diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index 86ee8e6a..3f8dd296 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -106,8 +106,15 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag /** Settles the store as idle for stop()-induced failures; returns true if handled. */ function settleIfAborted(error: unknown): boolean { // If we already settled this abort (duplicate delivery β€” e.g. RUN_ERROR - // event THEN onRunFailed), just swallow without touching the store again. - if (abortSettled && isAbortError(error)) return true; + // event THEN onRunFailed), defensively re-apply the idle settle so any + // state written between the two deliveries (e.g. RUN_STARTED from a new + // run that started before flags were reset) is corrected. Telemetry is + // not re-emitted β€” the guard returns true to suppress further processing. + if (abortSettled && isAbortError(error)) { + store.status.set('idle'); + store.isLoading.set(false); + return true; + } if (!abortRequested || !isAbortError(error)) return false; abortRequested = false; @@ -279,6 +286,12 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag if (store.isLoading()) { throw new Error('Cannot regenerate while agent is loading another response'); } + // Reset abort flags so a regenerate starts clean, exactly like submit(). + // Without this, flags left over from a prior stop() would cause the + // duplicate-delivery guard in settleIfAborted() to silently swallow the + // abort error without settling, wedging the store in streaming/running. + abortRequested = false; + abortSettled = false; const msgs = store.messages(); const target = msgs[assistantMessageIndex]; if (!target || target.role !== 'assistant') { @@ -314,10 +327,12 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag await source.runAgent(regenTools.length > 0 ? { tools: regenTools } : undefined); finishRunTelemetry(run); } catch (err) { - store.status.set('error'); - store.isLoading.set(false); - store.error.set(err); - failRunTelemetry(err, run); + if (!settleIfAborted(err)) { + store.status.set('error'); + store.isLoading.set(false); + store.error.set(err); + failRunTelemetry(err, run); + } } }, }; diff --git a/libs/chat/CHANGELOG.md b/libs/chat/CHANGELOG.md index ff566b4b..94ffdef6 100644 --- a/libs/chat/CHANGELOG.md +++ b/libs/chat/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - **`@angular/forms` peer dependency removed:** `chat-input` now binds its textarea with a direct `[value]`/`(input)` pair (fixes the composer keeping sent text under zoneless + OnPush). `@threadplane/chat` no longer requires `@angular/forms` β€” consumers may drop it unless they use it themselves. +- **json-render store isolation:** ``'s json-render message surfaces no longer fall back to the conversation-wide internal store β€” each surface self-seeds from its spec's `state` unless you pass an explicit `[store]`. Pass `[store]` (e.g. `signalStateStore({})`) when dashboards should receive backend agent state (STATE_SNAPSHOT) or share live values across surfaces; same-key dashboards in different messages are now isolated by default. Tool views (`chat-tool-views`) keep the previous shared-store behavior. - **Public API trim:** `@threadplane/chat` no longer re-exports `provideViews` / `VIEW_REGISTRY` from `@threadplane/render`. Consumers using `` / `` directly should import from `@threadplane/render`. For chat's markdown view overrides, provide `MARKDOWN_VIEW_REGISTRY` directly using `overrideViews(cacheplaneMarkdownViews, { … })` from `@threadplane/render` β€” the previously-documented `provideViews(withViews(…))` pattern never actually drove rendering. - **License:** `@threadplane/chat` is dual-licensed under PolyForm Noncommercial 1.0.0 (free noncommercial use) or a Threadplane Commercial license (production use inside a for-profit context). diff --git a/libs/chat/README.md b/libs/chat/README.md index d5d66a6d..b3c58ba0 100644 --- a/libs/chat/README.md +++ b/libs/chat/README.md @@ -36,7 +36,6 @@ npm install @threadplane/chat ``` @angular/core ^20.0.0 || ^21.0.0 @angular/common ^20.0.0 || ^21.0.0 -@angular/forms ^20.0.0 || ^21.0.0 @angular/platform-browser ^20.0.0 || ^21.0.0 @threadplane/licensing * @threadplane/render * From 0c607079c4edff17e8d445be7f8f34d152b375cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:58:43 +0000 Subject: [PATCH 15/16] chore(docs): regenerate api docs --- .../content/docs/chat/api/api-docs.json | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 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 5c52d48f..b8391a0a 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -2153,6 +2153,12 @@ "description": "", "optional": false }, + { + "name": "normalizedSpec", + "type": "Signal", + "description": "The bound spec with schema-documented `{ statePath }` prop refs\nrewritten to engine-native `{ $bindState }` + `_bindings` so values\nresolve against the state store instead of interpolating as\n\"[object Object]\" (F4).", + "optional": false + }, { "name": "registry", "type": "InputSignal", @@ -2393,6 +2399,19 @@ "description": "", "params": [] }, + { + "name": "onInput", + "signature": "onInput(event: Event): void", + "description": "Sync the textarea's value into the signal on user input. A direct\n [value]/(input) pair is used instead of ngModel: NgModel does not\n reliably write a programmatic clear back to the view under zoneless\n + OnPush, leaving sent text visible in the composer (audit F1).", + "params": [ + { + "name": "event", + "type": "Event", + "description": "", + "optional": false + } + ] + }, { "name": "onKeydown", "signature": "onKeydown(event: KeyboardEvent): void", @@ -2782,6 +2801,12 @@ "description": "", "optional": false }, + { + "name": "clientTools", + "type": "InputSignal> | undefined>", + "description": "Frontend-declared client tools forwarded to the inner ``.", + "optional": false + }, { "name": "closeOnEscape", "type": "InputSignal", @@ -3277,6 +3302,12 @@ "description": "", "optional": false }, + { + "name": "clientTools", + "type": "InputSignal> | undefined>", + "description": "Frontend-declared client tools forwarded to the inner ``.", + "optional": false + }, { "name": "closeOnEscape", "type": "InputSignal", @@ -4702,7 +4733,7 @@ { "name": "MarkdownChildrenComponent", "kind": "class", - "description": "Recursively dispatches a parent node's children through the markdown view\nregistry. Each child's `type` is looked up in the registry; the resolved\ncomponent is rendered with `[node]` bound to that child.\n\nIdentity-preserving: `track $any(child)` keys on the JS reference of the\nchild node. Because @cacheplane/partial-markdown preserves node identity\nacross pushes, unchanged subtrees never re-render.", + "description": "Recursively dispatches a parent node's children through the markdown view\nregistry. Each child's `type` is looked up in the registry; the resolved\ncomponent is rendered with `[node]` bound to that child.\n\nPosition-stable: `track $index` avoids NG0956 re-creation warnings that\noccur when the markdown pipeline re-parses content on every stream delta,\nproducing new child object references even for unchanged nodes.", "params": [], "examples": [], "properties": [ From 04b82516fe87191bc8cf1b87e38c8b4ef2ce00c2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 12 Jun 2026 06:04:26 -0700 Subject: [PATCH 16/16] ci: retrigger checks after bot docs regeneration Co-Authored-By: Claude Fable 5