From 0fc7725d57293ab5edcddc604b07b23adb8e8f1f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 08:44:51 -0700 Subject: [PATCH 01/24] docs(chat): design spec for the TypeScript DX pass Five-workstream plan to fix the @threadplane public-surface DX defects the audit found: the critical strict-mode tools() rejection + key/value widening, absent view/ask schema<->component linkage, agent generics not flowing through DI, action return type, hover readability, and JSDoc. Core type machinery for WS1 (variance-safe tools()) and WS2 (strict-but-flexible view/ask) verified compiling under strict:true. Co-Authored-By: Claude Fable 5 --- .../2026-06-18-typescript-dx-pass-design.md | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md diff --git a/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md b/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md new file mode 100644 index 00000000..9790ee07 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md @@ -0,0 +1,224 @@ +# TypeScript DX Pass — `@threadplane/*` Public API Design + +**Status:** Approved (brainstorm) — ready for implementation plan +**Date:** 2026-06-18 +**Scope:** Developer-facing TypeScript surface of `@threadplane/chat`, `@threadplane/render`, `@threadplane/langgraph`, `@threadplane/ag-ui`. + +## Goal + +Make the hero primitives an app developer touches first — `tools/action/view/ask`, `provideAgent/injectAgent`, the render/registry providers — infer correctly under `strict: true`, link schemas to handler args / component inputs / tool results, carry types through Angular DI, and read cleanly on hover. The target bar is the authored-elsewhere reference framework whose TypeScript DX the project owner wants to carry over: precise inference from minimal annotations, readable quick-info, and consistent inline JSDoc guidance. + +No backwards-compatibility constraint (the packages are pre-1.0, `0.0.x`). Flat named exports are kept (no namespace wrapper). + +## Motivating findings (from the audit) + +An empirical probe compiled scratch consumer code against the real published types and read back the inferred types from `tsc`. The defects below ship to npm consumers exactly as written (the `.d.ts` rollup is otherwise clean — no internal-type leaks). + +1. **Critical — `tools()` rejects every typed tool under `strict: true`.** `tools(map: Record)` takes the non-generic union whose handler is `(args: unknown) => …`. Under `strictFunctionTypes`, a typed `action()` whose handler demands `MoveInput` is not assignable there, so `tools({ move: action(...) })` is a compile error. It has never surfaced because both `examples/ag-ui/angular` and `examples/chat/angular` compile with `strict: false` (which disables `strictFunctionTypes`), while a default Angular CLI app is `strict: true`. +2. **High — `tools()` widens keys + values.** Return type loses per-key tool types (all collapse to `ClientToolDef`) and literal keys (`'move' | 'show'` widen to `string`), so there is no autocomplete or per-tool typing off the registry. +3. **High — `view()` / `ask()` have zero schema↔component linkage.** Signature is `(description, schema: StandardSchemaV1, component: Type)`. Any component compiles regardless of whether it can receive the props the model fills from the schema. +4. **High — `provideAgent` does not propagate `T` to `injectAgent`.** DI erases the generic; a dev must restate `injectAgent()` at every call site, and omitting it silently yields `Record`. +5. **High — typed state lives only on `.value`, not `.state`.** The runtime-neutral `state` signal that chat primitives and most docs reference stays `Signal>` even when the LangGraph-specific `value` is typed. +6. **Medium — `action`'s handler return type is hardcoded `unknown`** (the resolved value becomes the tool result, but its type is discarded). +7. **Medium — `view`/`ask` schema param is non-generic `StandardSchemaV1`** (output type discarded at the boundary; feeds #3). +8. **Minor — `StandardSchemaV1` is not re-exported from `@threadplane/chat`**, though `action/view/ask` all take it; missing `@param`/`@returns`/`@example` JSDoc on several hero exports. + +## Architecture + +Five independently-testable workstreams. The shared neutral contract (`Agent`, the schema-infer helpers, the agent ref) lives in `@threadplane/chat` / `@threadplane/render`; the adapters (`langgraph`, `ag-ui`) consume it. + +### WS1 — `tools()` / `action()` inference + +Fixes findings #1, #2, #6. + +```ts +// libs/chat/src/lib/client-tools/tool-def.ts + +/** Variance-safe base every tool-def kind extends. Handler param is the bottom + * type so a handler that demands a *specific* arg type stays assignable to it + * under strictFunctionTypes. Used only as the constraint bound in tools(). */ +export interface AnyClientToolDef { + kind: 'function' | 'view' | 'ask'; + description: string; + schema: StandardSchemaV1; + // function kind: + handler?: (args: never) => unknown | Promise; + // view/ask kind: + component?: Type; +} + +export interface FunctionToolDef { + kind: 'function'; + description: string; + schema: S; + handler: (args: StandardSchemaInferOutput) => R | Promise; +} +``` + +```ts +// libs/chat/src/lib/client-tools/tools.ts + +export function action( + description: string, + schema: S, + handler: (args: StandardSchemaInferOutput) => R | Promise, +): FunctionToolDef { + return { kind: 'function', description, schema, handler }; +} + +/** Collect named client tools into a frozen registry (the key is the tool name). + * Generic + const over the map so per-tool types and literal keys survive. */ +export function tools>(map: M): Readonly { + return Object.freeze({ ...map }); +} +``` + +Result: `tools({ move: action('…', moveSchema, h) })` compiles under `strict: true`; the returned registry's `move` key keeps its `FunctionToolDef` type and the key union is `'move'`. + +### WS2 — `view()` / `ask()` schema↔component linkage (strict-but-flexible) + +Fixes findings #3, #7. **Verified compiling under `strict: true`** (probe: good case + extra inputs accepted; typo, type-mismatch, and unrelated-component cases rejected). + +```ts +// libs/chat/src/lib/client-tools/component-inputs.ts (new) +import type { InputSignal, InputSignalWithTransform, Type } from '@angular/core'; + +type InputValue

= + P extends InputSignal ? T : + P extends InputSignalWithTransform ? T : never; + +/** The component instance's declared signal inputs, as a plain prop bag. */ +export type ComponentInputs = { + [K in keyof C as C[K] extends InputSignal | InputSignalWithTransform ? K : never]: + InputValue; +}; + +/** STRICT: every prop the schema PRODUCES must be a real input with an assignable + * type. FLEXIBLE: the component may declare extra inputs the schema doesn't fill. + * A schema key absent from the inputs maps to `never`, so its (non-never) value + * fails assignment and the error pins to that prop. */ +export type CompatibleProps = { + [K in keyof Out]: K extends keyof Inputs ? Inputs[K] : never; +}; + +export type AcceptComponent = + StandardSchemaInferOutput extends CompatibleProps, ComponentInputs> + ? Type + : ['✗ schema output is not assignable to this component’s inputs', + StandardSchemaInferOutput, ComponentInputs]; +``` + +```ts +// libs/chat/src/lib/client-tools/tools.ts +export function view( + description: string, schema: S, component: AcceptComponent, +): ViewToolDef { return { kind: 'view', description, schema, component: component as Type }; } + +export function ask( + description: string, schema: S, component: AcceptComponent, +): AskToolDef { return { kind: 'ask', description, schema, component: component as Type }; } + +/** Reverse helper: derive component input types FROM a schema, so a component + * authored straight from the schema is guaranteed compatible. */ +export type ViewProps = StandardSchemaInferOutput; +``` + +`ViewToolDef` / `AskToolDef` carry both the schema and the component type. They remain assignable to `AnyClientToolDef` (component widened to `Type` in the base), so `tools({...})` accepts them. + +**Known limitation (intentional):** "every *required* input must be covered by the schema" is not enforceable at compile time — Angular does not brand required inputs in the type system (`input.required()` and `input(default)` are both `InputSignal`). The shipped runtime schema-readiness gate (holds the fallback until streamed props validate) is the backstop for that case. Compile-time blocks structural mismatches; runtime blocks incomplete/missing props. + +### WS3 — agent typing through DI + +Fixes findings #4, #5. Highest-surface workstream. + +```ts +// libs/chat/src/lib/agent/agent.ts +export interface Agent> { + // …existing members… + readonly state: Signal; // was Signal> +} +``` + +The `= Record` default means every existing use of bare `Agent` is unchanged. + +```ts +// libs/chat/src/lib/agent/agent-ref.ts (new) +/** A typed handle that threads a state shape through Angular DI from + * provideAgent() to injectAgent() without per-call-site restatement. */ +export interface AgentRef { readonly token: InjectionToken>; } +export function createAgentRef(debugName?: string): AgentRef { + return { token: new InjectionToken>(debugName ?? 'ThreadplaneAgent') }; +} +``` + +Both adapters gain a ref-aware overload while keeping the no-arg form: + +```ts +// libs/langgraph + libs/ag-ui (analogous) +export function provideAgent(ref: AgentRef, config: AgentConfig | (() => AgentConfig)): Provider[]; +export function provideAgent(config: AgentConfig | (() => AgentConfig)): Provider[]; // default token + +export function injectAgent(): LangGraphAgent; // default state +export function injectAgent(ref: AgentRef): LangGraphAgent; // typed via ref +``` + +The bare generic `injectAgent()` (which never propagated) is removed. + +### WS4 — hovers + discoverability + +Fixes finding #8 and readability across the board. + +- Internal `Prettify = { [K in keyof T]: T[K] } & {}` applied to the public *inferred* types (`FunctionToolDef`, `ViewToolDef`/`AskToolDef`, `ViewProps`, the agent state surface) so quick-info shows the expanded object instead of raw conditional/mapped-type expressions. `Prettify` itself is `@internal`. +- Re-export from `@threadplane/chat`: `StandardSchemaV1`, `StandardSchemaInferInput`, `StandardSchemaInferOutput`, and a convenience alias `ToolArgs = StandardSchemaInferOutput` so a consumer can type a handler/arg without reaching into `@threadplane/render`. +- `@param` / `@returns` / `@example` JSDoc on every hero export: `tools`, `action`, `view`, `ask`, `provideChat`, `provideRender`, `defineAngularRegistry`, `injectRenderHost`, `provideAgent`/`injectAgent` (both adapters), `createAgentRef`, and the `views`/`withViews`/`overrideViews`/`withoutViews`/`toRenderRegistry` helpers. + +### WS5 — regression gate (the TDD spine) + +A hand-rolled type-assertion kit (no new dependency): + +```ts +// libs/chat/src/testing/type-assert.ts (or a shared internal util) +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +export type Expect = T; +``` + +`*.type-spec.ts` files compiled under a dedicated **`strict: true`** tsconfig per touched lib, run in CI via `tsc --noEmit -p tsconfig.type-tests.json`. They assert the public behavior: +- `tools({ x: action('…', s, h) })` compiles; the result's `x` key keeps `FunctionToolDef<…>`; the key union is the literal `'x'`. +- `action` handler arg is the schema output; the carried return type `R` flows. +- the three `view`/`ask` rejection cases hold (typo prop, type mismatch, unrelated component) via `// @ts-expect-error`; the good case (incl. extra inputs) compiles. +- `injectAgent(ref)` is `Agent`; `agent.state` is `Signal`. + +Whole existing unit suites stay green; one example app is built to confirm lib source still compiles. + +## Files touched + +- `libs/chat/src/lib/client-tools/tools.ts` — generic `tools`, `action`, `view`, `ask`, `ViewProps`. +- `libs/chat/src/lib/client-tools/tool-def.ts` — `AnyClientToolDef`, `FunctionToolDef`, `ViewToolDef`, `AskToolDef`. +- `libs/chat/src/lib/client-tools/component-inputs.ts` *(new)* — `ComponentInputs`, `CompatibleProps`, `AcceptComponent`. +- `libs/chat/src/lib/agent/agent.ts` — `Agent>`. +- `libs/chat/src/lib/agent/agent-ref.ts` *(new)* — `AgentRef`, `createAgentRef`. +- `libs/chat/src/public-api.ts` — re-exports (`StandardSchema*`, `ToolArgs`, `ViewProps`, `AgentRef`, `createAgentRef`) + barrel updates. +- `libs/render/src/lib/standard-schema.ts` — confirm `StandardSchemaInfer{Input,Output}` are exported (already present); add `Prettify` internal if hosted here. +- `libs/langgraph/src/lib/agent.provider.ts`, `inject-agent.ts`, `public-api.ts` — ref overloads, `LangGraphAgent` already generic. +- `libs/ag-ui/src/lib/provide-agent.ts`, `to-agent.ts`, `public-api.ts` — genericize `AgUiAgent`, ref overloads. +- JSDoc across all the above hero exports. +- `*.type-spec.ts` + `tsconfig.type-tests.json` in `libs/chat`, `libs/langgraph` (+ project.json target wiring). +- Docs/examples updated to the new signatures (`tools` typed registry, `view/ask` generic, `createAgentRef` usage). Examples keep their current `strict` setting; the type-test gate is the strict-mode guard. + +## Testing strategy + +TDD via WS5: write the failing strict type-test, then make it pass. Each workstream's type-spec is its acceptance gate. Existing vitest suites (render, chat, langgraph, ag-ui) must stay green. Build one example app (e.g. `examples-chat-angular`) to confirm lib source still compiles in an app context. + +## Risks & decisions + +- **`Agent` genericization (WS3) is the highest-surface change.** The `= Record` default contains the ripple, but it touches the shared contract and both adapters. Accepted as in-scope (comprehensive spec); WS3 is the natural split point if it later needs to be de-risked into its own PR. +- **`const` type parameter on `tools()`** requires TypeScript ≥ 5.0; the repo is well past that. +- **The `view`/`ask` `never`/error-tuple constraint** must keep `ViewToolDef`/`AskToolDef` assignable to `AnyClientToolDef` so `tools({...})` still accepts views/asks — covered by a WS5 type-test. + +## Explicitly NOT in scope + +- A namespace/`s.*`-style export wrapper (decided: keep flat named exports). +- Enforcing required-input coverage at compile time for `view`/`ask` (not expressible; runtime gate covers it). +- `@threadplane/a2ui` JSDoc and the broader cockpit/internal packages (not developer-facing hero APIs). +- The separate error-UX and thread-persistence follow-ups. From 10b1bc68372594fa832c4c179043a5923f1440d4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 08:48:11 -0700 Subject: [PATCH 02/24] =?UTF-8?q?docs(chat):=20strengthen=20DX-pass=20vali?= =?UTF-8?q?dation=20=E2=80=94=20examples=20as=20forcing=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a four-tier validation strategy that uses the cockpit client-tools examples and the canonical langgraph + ag-ui examples as forcing functions: Tier 1 migrates the client-tools apps + canonical examples/ag-ui to the typed APIs AND flips them to strict:true (the flag that hid the original bug); Tier 2 builds all ~35 provideAgent/injectAgent apps as a non-breaking net for the Agent genericization; Tier 3 live-smokes the client-tools + canonical apps; Tier 4 keeps the unit type-spec gate. Co-Authored-By: Claude Fable 5 --- .../2026-06-18-typescript-dx-pass-design.md | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md b/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md index 9790ee07..9437873b 100644 --- a/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md +++ b/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md @@ -204,15 +204,37 @@ Whole existing unit suites stay green; one example app is built to confirm lib s - `libs/ag-ui/src/lib/provide-agent.ts`, `to-agent.ts`, `public-api.ts` — genericize `AgUiAgent`, ref overloads. - JSDoc across all the above hero exports. - `*.type-spec.ts` + `tsconfig.type-tests.json` in `libs/chat`, `libs/langgraph` (+ project.json target wiring). -- Docs/examples updated to the new signatures (`tools` typed registry, `view/ask` generic, `createAgentRef` usage). Examples keep their current `strict` setting; the type-test gate is the strict-mode guard. +- **Tier-1 forcing-function apps flipped to `strict: true` and migrated to the typed APIs:** `cockpit/ag-ui/client-tools/angular`, `cockpit/langgraph/client-tools/angular`, `examples/ag-ui/angular` (tsconfig + the `client-tools*.ts` / `app.config.ts` call sites + the view/ask component input types). Fallout fixed in example code. +- **Tier-2 representative migrations** to `createAgentRef` typed state: `examples/chat/angular` + one `cockpit/langgraph/*` + one `cockpit/chat/*`. +- Docs updated to the new signatures (`tools` typed registry, `view/ask` generic, `createAgentRef` usage). ## Testing strategy -TDD via WS5: write the failing strict type-test, then make it pass. Each workstream's type-spec is its acceptance gate. Existing vitest suites (render, chat, langgraph, ag-ui) must stay green. Build one example app (e.g. `examples-chat-angular`) to confirm lib source still compiles in an app context. +TDD via WS5: write the failing strict type-test, then make it pass. Each workstream's type-spec is its acceptance gate. Existing vitest suites (render, chat, langgraph, ag-ui) must stay green. + +### Validation via examples (forcing functions) — required, thorough + +The unit type-tests are necessary but not sufficient: the critical bug survived precisely because every example compiles with `strict: false`. The real, non-negotiable acceptance gate is that the **cockpit examples and the canonical `langgraph` + `ag-ui` examples**, compiled the way a real consumer compiles, exercise the new typed APIs and pass. Four tiers: + +**Tier 1 — typed-API migration + full `strict: true` (deep forcing functions).** Migrate and flip these apps to `strict: true`, fixing any fallout in the example code: +- `cockpit/ag-ui/client-tools/angular` +- `cockpit/langgraph/client-tools/angular` +- `examples/ag-ui/angular` (the canonical itinerary demo — uses `tools/view/ask` + `provideAgent`/`injectAgent`) + +Each is migrated to: a typed `tools({...})` registry; generic `view`/`ask` paired with components whose signal inputs match the schema output (WS2 linkage actively exercised, including at least one intentionally-correct non-trivial schema↔component pair); and `createAgentRef()` for typed agent state. Compiled under full `strict: true`, these reproduce exactly how an Angular CLI consumer builds — so if WS1/WS2/WS3 regress, these apps fail to compile. + +**Tier 2 — build-regression net (proves WS3 genericization is non-breaking).** All ~35 cockpit + canonical apps that call `provideAgent`/`injectAgent` must still build green on their existing settings (the `Agent>` default must keep them untouched). Run `nx run-many -t build` across every affected angular project. Additionally migrate `examples/chat/angular` and one representative `cockpit/langgraph/*` and `cockpit/chat/*` app to `createAgentRef` typed state as extra forcing functions. + +**Tier 3 — live smoke (runtime correctness).** Per the live-LLM-smoke gate, serve with a real key and drive: both client-tools apps (`cockpit/ag-ui/client-tools`, `cockpit/langgraph/client-tools`), the canonical `examples/ag-ui` itinerary, and `examples/chat`. Confirm the typed-API changes did not alter runtime behavior — tool flows complete, zero console errors. (Type changes are erased at runtime, but the migrations touch real call sites, so this guards against accidental behavioral edits.) + +**Tier 4 — type-test gate (WS5).** The hand-rolled `strict:true` type-spec files remain the fast unit-level guard that runs in CI on every change. + +A workstream is "done" only when its Tier-1 forcing-function apps compile under `strict: true`, the Tier-2 build net is green, and (for the client-tools/canonical apps) the Tier-3 live smoke passes. ## Risks & decisions -- **`Agent` genericization (WS3) is the highest-surface change.** The `= Record` default contains the ripple, but it touches the shared contract and both adapters. Accepted as in-scope (comprehensive spec); WS3 is the natural split point if it later needs to be de-risked into its own PR. +- **`Agent` genericization (WS3) is the highest-surface change.** The `= Record` default contains the ripple, but it touches the shared contract and both adapters. The Tier-2 build net (all ~35 `provideAgent`/`injectAgent` apps building green on their existing settings) is the explicit proof that the default truly contains it. Accepted as in-scope (comprehensive spec); WS3 is the natural split point if it later needs to be de-risked into its own PR. +- **Flipping Tier-1 apps to full `strict: true` may surface unrelated example-code issues** (implicit `any`, etc.) beyond the typed-API changes. These are fixed in the example code as part of the migration; if the fallout in any one app proves disproportionate, fall back to enabling `strictFunctionTypes: true` alone (the exact flag that masked the bug) for that app and note it. - **`const` type parameter on `tools()`** requires TypeScript ≥ 5.0; the repo is well past that. - **The `view`/`ask` `never`/error-tuple constraint** must keep `ViewToolDef`/`AskToolDef` assignable to `AnyClientToolDef` so `tools({...})` still accepts views/asks — covered by a WS5 type-test. From 15354259cf7deb76e02cfc1613c2d3f41387b74b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 08:55:55 -0700 Subject: [PATCH 03/24] docs(chat): TDD implementation plan for the TypeScript DX pass 18 tasks across the five workstreams + the four-tier example validation (forcing functions). Each lib change is gated by a strict:true type-spec; the cockpit client-tools apps + canonical examples/ag-ui are flipped to strict:true and migrated to the typed APIs. Also corrects the spec's WS1 variance sketch (any-param bivariant member, verified to support internal handler calls). Co-Authored-By: Claude Fable 5 --- .../plans/2026-06-18-typescript-dx-pass.md | 1157 +++++++++++++++++ .../2026-06-18-typescript-dx-pass-design.md | 52 +- 2 files changed, 1188 insertions(+), 21 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-typescript-dx-pass.md diff --git a/docs/superpowers/plans/2026-06-18-typescript-dx-pass.md b/docs/superpowers/plans/2026-06-18-typescript-dx-pass.md new file mode 100644 index 00000000..6df46240 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-typescript-dx-pass.md @@ -0,0 +1,1157 @@ +# TypeScript DX Pass 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:** Make the `@threadplane/*` hero primitives (`tools/action/view/ask`, `provideAgent/injectAgent`) infer correctly under `strict: true`, link schemas to handler args / component inputs, carry agent state types through DI, and read cleanly on hover — validated against the cockpit + canonical examples as forcing functions. + +**Architecture:** Five library workstreams in `@threadplane/chat`, `@threadplane/render`, `@threadplane/langgraph`, `@threadplane/ag-ui`, each gated by a hand-rolled `strict: true` type-test file. Then a four-tier example-validation pass: migrate + flip `strict: true` on the client-tools forcing-function apps, build-net across all `provideAgent`/`injectAgent` apps, live smoke, and the type-test gate in CI. + +**Tech Stack:** Angular 21 (signal inputs, `InputSignal`), TypeScript ≥ 5 (`const` type params), Nx, vitest, Standard Schema (vendored), Zod (`zod/v4`) in examples. + +**Reference spec:** `docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md`. Core type machinery for WS1 + WS2 was verified compiling under `strict: true` during design. + +**Conventions for the implementer:** +- No backwards-compatibility constraint. Flat named exports stay. +- Never name the reference framework in code/comments/commits (describe techniques generically). +- The render lib's vitest has no Angular template compiler; type-level behavior is validated by `*.type-spec.ts` files compiled with `tsc`, not by runtime tests. +- Type-test TDD loop: the "failing test" is a `tsc -p ` run that errors before the implementation lands and passes after. `@ts-expect-error` lines invert the assertion (an *unused* `@ts-expect-error` is itself a compile error, so they double as negative tests). +- Commit after each task. + +--- + +## File Structure + +**`libs/chat`** +- `src/lib/client-tools/tool-def.ts` — MODIFY: `FunctionToolDef`, new `AnyFunctionToolDef`, `ViewToolDef`, `AskToolDef`, `ClientToolDef` union. +- `src/lib/client-tools/tools.ts` — MODIFY: `action`, generic `tools`, generic `view`/`ask`, `ViewProps` re-export point. +- `src/lib/client-tools/component-inputs.ts` — NEW: `ComponentInputs`, `CompatibleProps`, `AcceptComponent`. +- `src/lib/agent/agent.ts` — MODIFY: `Agent>`. +- `src/lib/agent/agent-with-history.ts` — MODIFY: `AgentWithHistory>`. +- `src/lib/agent/agent-ref.ts` — NEW: `AgentRef`, `createAgentRef`. +- `src/lib/internals/prettify.ts` — NEW: `Prettify` (internal). +- `src/lib/client-tools/*.type-spec.ts`, `src/lib/agent/*.type-spec.ts` — NEW type-tests. +- `src/testing/type-assert.ts` — NEW: `Equal`, `Expect` (shared by type-specs; no vitest dep). +- `tsconfig.type-tests.json` — NEW: strict tsconfig for type-specs. +- `project.json` — MODIFY: add `type-tests` target. +- `src/public-api.ts` — MODIFY: re-exports + JSDoc-touched symbols. + +**`libs/langgraph`** +- `src/lib/agent.provider.ts`, `src/lib/inject-agent.ts`, `src/lib/agent.types.ts` — MODIFY: ref overloads; `LangGraphAgent` already generic. +- `src/lib/*.type-spec.ts`, `tsconfig.type-tests.json`, `project.json` — NEW type-test gate. + +**`libs/ag-ui`** +- `src/lib/to-agent.ts`, `src/lib/provide-agent.ts` — MODIFY: `AgUiAgent`, ref overloads. + +**Examples (forcing functions)** +- `cockpit/ag-ui/client-tools/angular/`, `cockpit/langgraph/client-tools/angular/`, `examples/ag-ui/angular/` — MODIFY: typed APIs + `strict: true`. +- `examples/chat/angular/`, one `cockpit/langgraph/*`, one `cockpit/chat/*` — MODIFY: `createAgentRef`. + +--- + +## Task 1: Type-test harness (WS5 foundation) + +**Files:** +- Create: `libs/chat/src/testing/type-assert.ts` +- Create: `libs/chat/tsconfig.type-tests.json` +- Create: `libs/chat/src/lib/client-tools/tools.type-spec.ts` (smoke) +- Modify: `libs/chat/project.json` + +- [ ] **Step 1: Create the type-assertion kit** + +`libs/chat/src/testing/type-assert.ts`: +```ts +// SPDX-License-Identifier: MIT +/** Compile-time assertion helpers for *.type-spec.ts files (no runtime, no vitest dep). */ + +/** Exact-type equality (invariant). `Equal` is `true` iff A and B are identical. */ +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +/** Passes only when `T` is exactly `true`; otherwise a compile error. */ +export type Expect = T; + +/** True if `A` is assignable to `B`. */ +export type Assignable = A extends B ? true : false; +``` + +- [ ] **Step 2: Create the strict type-tests tsconfig** + +`libs/chat/tsconfig.type-tests.json`: +```json +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.type-spec.ts", "src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} +``` + +- [ ] **Step 3: Add a smoke type-spec that should pass immediately** + +`libs/chat/src/lib/client-tools/tools.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { Equal, Expect } from '../../testing/type-assert'; + +// Harness smoke — proves the type-test pipeline runs. +type _smoke = Expect>; +``` + +- [ ] **Step 4: Add the nx `type-tests` target** + +In `libs/chat/project.json`, add to `targets`: +```json +"type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit -p libs/chat/tsconfig.type-tests.json" + } +} +``` + +- [ ] **Step 5: Run the gate — expect PASS (harness only)** + +Run: `npx nx type-tests chat` +Expected: exits 0 (only the smoke assertion present). + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/testing/type-assert.ts libs/chat/tsconfig.type-tests.json \ + libs/chat/src/lib/client-tools/tools.type-spec.ts libs/chat/project.json +git commit -m "test(chat): strict type-test harness (Equal/Expect + tsconfig + nx target)" +``` + +--- + +## Task 2: WS1 — variance-safe tool-def types + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` +- Modify: `libs/chat/src/lib/client-tools/tools.type-spec.ts` + +- [ ] **Step 1: Write the failing type-spec** + +Replace the body of `libs/chat/src/lib/client-tools/tools.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { Equal, Expect } from '../../testing/type-assert'; +import type { StandardSchemaV1 } from '@threadplane/render'; +import type { FunctionToolDef, ClientToolDef } from './tool-def'; +import { action, tools } from './tools'; +import { z } from 'zod/v4'; + +const moveSchema = z.object({ fromDay: z.number(), placeId: z.string() }); + +// action() infers handler arg from the schema output and carries the return type R. +const moveAction = action('Move a stop', moveSchema, (a) => a.fromDay + 1); +type _argInfer = Expect[0], { fromDay: number; placeId: string }>>; +type _retInfer = Expect>>; + +// A precise FunctionToolDef must be assignable into the bivariant union. +const _u: ClientToolDef = moveAction; + +// tools() preserves per-key tool types AND literal keys under strict. +const registry = tools({ + move_stop: moveAction, + note: action('Note', z.object({ text: z.string() }), (a) => a.text), +}); +type _keys = Expect>; +type _perKey = Expect>>; +``` + +- [ ] **Step 2: Run the gate — expect FAIL** + +Run: `npx nx type-tests chat` +Expected: FAIL — `tools()` currently returns `ClientToolRegistry` (widened), so `_keys`/`_perKey` error; `FunctionToolDef` has no `R` param so `_retInfer` errors. + +- [ ] **Step 3: Rewrite `tool-def.ts`** + +`libs/chat/src/lib/client-tools/tool-def.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { Type } from '@angular/core'; +import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; + +/** Precise authored function tool — what `action()` returns. Carries the schema + * `S` and the handler's resolved return type `R`. */ +export interface FunctionToolDef { + readonly kind: 'function'; + readonly description: string; + readonly schema: S; + readonly handler: (args: StandardSchemaInferOutput) => R | Promise; +} + +/** Bivariant union member used only for registry storage/iteration. The handler + * param is `any` (NOT `never`): `any` is simultaneously a supertype any precise + * `FunctionToolDef` is assignable to under `strictFunctionTypes`, AND + * callable by internal code that has narrowed by `kind` and parsed runtime args. + * A `never` param would satisfy the former but break the latter. */ +export interface AnyFunctionToolDef { + readonly kind: 'function'; + readonly description: string; + readonly schema: StandardSchemaV1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariance escape hatch; see note above + readonly handler: (args: any) => unknown | Promise; +} + +export interface ViewToolDef { + readonly kind: 'view'; + readonly description: string; + readonly schema: S; + readonly component: Type; +} + +export interface AskToolDef { + readonly kind: 'ask'; + readonly description: string; + readonly schema: S; + readonly component: Type; +} + +/** A client tool the model can call; executed in the browser. */ +export type ClientToolDef = + | AnyFunctionToolDef + | ViewToolDef + | AskToolDef; + +/** A frozen, name-keyed registry of client tools. */ +export type ClientToolRegistry = Readonly>; +``` + +- [ ] **Step 4: Run the gate — expect partial progress** + +Run: `npx nx type-tests chat` +Expected: still FAILS on `_keys`/`_perKey` (tools.ts not yet generic) but `_retInfer`/`_argInfer`/`_u` now depend on Task 3. Proceed to Task 3 before re-running. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/client-tools/tool-def.ts +git commit -m "feat(chat): variance-safe ClientToolDef (FunctionToolDef + bivariant AnyFunctionToolDef)" +``` + +--- + +## Task 3: WS1 — generic `action` + `tools` + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tools.ts` + +- [ ] **Step 1: Rewrite `action` and `tools` (view/ask updated in Task 5)** + +In `libs/chat/src/lib/client-tools/tools.ts`, replace the `action` and `tools` definitions (leave `view`/`ask` for Task 5). Update the imports to add `ClientToolDef` and the `FunctionToolDef` generic: +```ts +// SPDX-License-Identifier: MIT +import type { Type } from '@angular/core'; +import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; +import type { ClientToolDef, FunctionToolDef } from './tool-def'; + +/** Async function tool — its resolved return value becomes the tool result. */ +export function action( + description: string, + schema: S, + handler: (args: StandardSchemaInferOutput) => R | Promise, +): FunctionToolDef { + return { kind: 'function', description, schema, handler }; +} + +// ... view()/ask() remain here for now (updated in Task 5) ... + +/** Collect named client tools into a frozen registry (the key is the tool name). + * Generic + `const` over the map so per-tool types and literal keys survive. */ +export function tools>(map: M): Readonly { + return Object.freeze({ ...map }); +} +``` + +- [ ] **Step 2: Run the gate — expect the WS1 assertions to PASS** + +Run: `npx nx type-tests chat` +Expected: PASS for all assertions in `tools.type-spec.ts` (`_argInfer`, `_retInfer`, `_u`, `_keys`, `_perKey`). + +- [ ] **Step 3: Run the chat unit suite + lint to catch internal call-site fallout** + +Run: `npx nx run-many -t test lint --projects=chat --skip-nx-cache` +Expected: PASS. The `any`-param member keeps `coordinator`/`executor` `def.handler(args)` calls valid. If any internal site that previously relied on `ClientToolRegistry` now sees a narrowed type, fix by reading through `ClientToolDef` (no behavior change). + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/client-tools/tools.ts +git commit -m "feat(chat): generic tools()/action() — strict-safe registry with per-key + literal-key types" +``` + +--- + +## Task 4: WS2 — component-input extraction types + +**Files:** +- Create: `libs/chat/src/lib/client-tools/component-inputs.ts` +- Create: `libs/chat/src/lib/client-tools/view-ask.type-spec.ts` + +- [ ] **Step 1: Create the type machinery** + +`libs/chat/src/lib/client-tools/component-inputs.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { InputSignal, InputSignalWithTransform, Type } from '@angular/core'; +import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; + +/** Value type carried by an Angular signal input. */ +type InputValue

= + P extends InputSignal ? T : + P extends InputSignalWithTransform ? T : + never; + +/** A component instance's declared signal inputs, as a plain prop bag. */ +export type ComponentInputs = { + [K in keyof C as C[K] extends InputSignal | InputSignalWithTransform + ? K + : never]: InputValue; +}; + +/** STRICT: every prop the schema PRODUCES must be a declared input with an + * assignable type. FLEXIBLE: the component may declare extra inputs the schema + * doesn't fill. A schema key absent from `Inputs` maps to `never`, so its + * (non-never) value fails assignment and the error pins to that prop. */ +export type CompatibleProps = { + [K in keyof Out]: K extends keyof Inputs ? Inputs[K] : never; +}; + +/** The accepted `component` parameter type for `view`/`ask`: the real component + * `Type` when the schema output is compatible, else a labelled error tuple + * that surfaces both shapes in the compiler message. */ +export type AcceptComponent = + StandardSchemaInferOutput extends CompatibleProps, ComponentInputs> + ? Type + : readonly [ + 'Schema output is not assignable to this component’s inputs', + StandardSchemaInferOutput, + ComponentInputs, + ]; + +/** Reverse helper: derive a component's input prop types FROM a schema, so a + * component authored straight from the schema is guaranteed compatible. */ +export type ViewProps = StandardSchemaInferOutput; +``` + +- [ ] **Step 2: Write the failing type-spec (good + 3 bad + assignable-to-union)** + +`libs/chat/src/lib/client-tools/view-ask.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import { Component, input } from '@angular/core'; +import { z } from 'zod/v4'; +import type { ClientToolDef, ViewToolDef } from './tool-def'; +import { view, ask } from './tools'; +import { tools } from './tools'; + +@Component({ template: '' }) +class DayCardComponent { + day = input.required(); + places = input([]); // optional (default) + highlight = input(false); // extra input NOT in schema +} + +@Component({ template: '' }) +class UnrelatedComponent { + title = input.required(); +} + +const daySchema = z.object({ day: z.number(), places: z.array(z.string()) }); + +// ✅ good — schema output keys ⊆ inputs, compatible types; extra `highlight` allowed. +const dayView = view('Show a day', daySchema, DayCardComponent); + +// the view tool stays assignable to the registry union and through tools(). +const _u: ClientToolDef = dayView; +const _reg = tools({ day_card: view('Show a day', daySchema, DayCardComponent) }); + +// the result carries the component type. +const _carries: ViewToolDef = dayView; + +// ❌ typo prop the component can't receive. +const typoSchema = z.object({ dayz: z.number() }); +// @ts-expect-error `dayz` is not an input of DayCardComponent +const _bad1 = view('typo', typoSchema, DayCardComponent); + +// ❌ type mismatch (day: string vs input number). +const wrongType = z.object({ day: z.string() }); +// @ts-expect-error day: string not assignable to input day: number +const _bad2 = view('wrong type', wrongType, DayCardComponent); + +// ❌ unrelated component. +// @ts-expect-error schema output {day, places} has no matching inputs on UnrelatedComponent +const _bad3 = ask('unrelated', daySchema, UnrelatedComponent); +``` + +- [ ] **Step 3: Run the gate — expect FAIL** + +Run: `npx nx type-tests chat` +Expected: FAIL — `view`/`ask` are still non-generic (`component: Type`), so the good case has no linkage and the three `@ts-expect-error` lines are *unused* (which is itself a compile error). Implemented in Task 5. + +- [ ] **Step 4: Commit (machinery only; spec stays red until Task 5)** + +```bash +git add libs/chat/src/lib/client-tools/component-inputs.ts libs/chat/src/lib/client-tools/view-ask.type-spec.ts +git commit -m "feat(chat): ComponentInputs/AcceptComponent type machinery for view/ask linkage" +``` + +--- + +## Task 5: WS2 — generic `view` / `ask` + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tools.ts` + +- [ ] **Step 1: Make `view`/`ask` generic + export `ViewProps`** + +In `libs/chat/src/lib/client-tools/tools.ts`, replace the `view` and `ask` functions and extend imports: +```ts +import type { ViewToolDef, AskToolDef } from './tool-def'; +import type { AcceptComponent } from './component-inputs'; +export type { ViewProps } from './component-inputs'; + +/** Render-only component tool — the model fills its props; auto-acknowledged. + * The component's signal inputs are checked against the schema's output type. */ +export function view( + description: string, + schema: S, + component: AcceptComponent, +): ViewToolDef { + return { kind: 'view', description, schema, component: component as Type }; +} + +/** Interactive (HITL) component tool — the value it emits becomes the result. + * The component's signal inputs are checked against the schema's output type. */ +export function ask( + description: string, + schema: S, + component: AcceptComponent, +): AskToolDef { + return { kind: 'ask', description, schema, component: component as Type }; +} +``` + +- [ ] **Step 2: Run the gate — expect PASS** + +Run: `npx nx type-tests chat` +Expected: PASS — good case compiles, `_u`/`_reg`/`_carries` hold, all three `@ts-expect-error` lines are now *used* (the bad cases genuinely error). + +- [ ] **Step 3: Run chat unit suite + lint** + +Run: `npx nx run-many -t test lint --projects=chat --skip-nx-cache` +Expected: PASS (the `as Type` cast keeps the runtime object shape identical; coordinator reads `component` unchanged). + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/client-tools/tools.ts +git commit -m "feat(chat): generic view()/ask() — strict-but-flexible schema<->component input linkage" +``` + +--- + +## Task 6: WS3 — genericize the neutral `Agent` contract + +**Files:** +- Modify: `libs/chat/src/lib/agent/agent.ts` +- Modify: `libs/chat/src/lib/agent/agent-with-history.ts` + +- [ ] **Step 1: Genericize `Agent`** + +In `libs/chat/src/lib/agent/agent.ts`, change the interface declaration and the `state` member: +```ts +export interface Agent> { + // ...unchanged members... + state: Signal; + // ...rest unchanged... +} +``` +(Only the `interface Agent` line and `state:` line change; everything else stays.) + +- [ ] **Step 2: Thread the param through `AgentWithHistory`** + +In `libs/chat/src/lib/agent/agent-with-history.ts`: +```ts +export interface AgentWithHistory> extends Agent { + history: Signal; + messageCheckpoints?: Signal>; +} +``` + +- [ ] **Step 3: Build chat + the adapters to confirm the default contains the ripple** + +Run: `npx nx run-many -t build --projects=chat,langgraph,ag-ui --skip-nx-cache` +Expected: PASS. The `= Record` default means every existing `Agent` / `AgentWithHistory` reference is unchanged. If `LangGraphAgent extends AgentWithHistory` now needs `AgentWithHistory` to surface typed `state`, that is done in Task 8 — a plain build here should still pass because `LangGraphAgent` defaulting keeps it valid. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/agent/agent.ts libs/chat/src/lib/agent/agent-with-history.ts +git commit -m "feat(chat): genericize Agent + AgentWithHistory (default Record)" +``` + +--- + +## Task 7: WS3 — `createAgentRef` typed DI handle + +**Files:** +- Create: `libs/chat/src/lib/agent/agent-ref.ts` +- Modify: `libs/chat/src/lib/agent/index.ts` +- Create: `libs/chat/src/lib/agent/agent-ref.type-spec.ts` + +- [ ] **Step 1: Write the failing type-spec** + +`libs/chat/src/lib/agent/agent-ref.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { InjectionToken } from '@angular/core'; +import type { Equal, Expect } from '../../testing/type-assert'; +import type { Agent } from './agent'; +import { createAgentRef, type AgentRef } from './agent-ref'; + +interface TripState { day: number; places: string[]; } + +const trip = createAgentRef('trip'); +type _refTyped = Expect>>; +type _tokenTyped = Expect>>>; +``` + +- [ ] **Step 2: Run the gate — expect FAIL** + +Run: `npx nx type-tests chat` +Expected: FAIL — module `./agent-ref` does not exist. + +- [ ] **Step 3: Create `agent-ref.ts`** + +`libs/chat/src/lib/agent/agent-ref.ts`: +```ts +// SPDX-License-Identifier: MIT +import { InjectionToken } from '@angular/core'; +import type { Agent } from './agent'; + +/** A typed handle that threads a state shape through Angular DI from + * `provideAgent(ref, …)` to `injectAgent(ref)` without per-call-site + * restatement of the generic. */ +export interface AgentRef { + readonly token: InjectionToken>; +} + +/** + * Create a typed agent handle. + * + * @param debugName Optional name shown in Angular DI error messages. + * @returns An {@link AgentRef} carrying a state-typed `InjectionToken`. + * @example + * ```ts + * interface TripState { day: number; places: string[]; } + * export const TRIP = createAgentRef('trip'); + * // app.config.ts: provideAgent(TRIP, { assistantId: 'trip' }) + * // component: const agent = injectAgent(TRIP); // LangGraphAgent + * ``` + */ +export function createAgentRef(debugName?: string): AgentRef { + return { token: new InjectionToken>(debugName ?? 'ThreadplaneAgent') }; +} +``` + +- [ ] **Step 4: Export from the agent barrel** + +In `libs/chat/src/lib/agent/index.ts`, add: +```ts +export type { AgentRef } from './agent-ref'; +export { createAgentRef } from './agent-ref'; +``` + +- [ ] **Step 5: Run the gate — expect PASS** + +Run: `npx nx type-tests chat` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/agent/agent-ref.ts libs/chat/src/lib/agent/index.ts libs/chat/src/lib/agent/agent-ref.type-spec.ts +git commit -m "feat(chat): createAgentRef typed DI handle (AgentRef)" +``` + +--- + +## Task 8: WS3 — LangGraph `provideAgent`/`injectAgent` ref overloads + +**Files:** +- Modify: `libs/langgraph/src/lib/agent.types.ts` (LangGraphAgent extends `AgentWithHistory`) +- Modify: `libs/langgraph/src/lib/agent.provider.ts` +- Modify: `libs/langgraph/src/lib/inject-agent.ts` +- Create: `libs/langgraph/tsconfig.type-tests.json`, `libs/langgraph/src/lib/inject-agent.type-spec.ts` +- Modify: `libs/langgraph/project.json` + +- [ ] **Step 1: Set up the langgraph type-test gate** + +`libs/langgraph/tsconfig.type-tests.json`: +```json +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.type-spec.ts", "src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} +``` +Add to `libs/langgraph/project.json` `targets`: +```json +"type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit -p libs/langgraph/tsconfig.type-tests.json" + } +} +``` + +- [ ] **Step 2: Write the failing type-spec** + +`libs/langgraph/src/lib/inject-agent.type-spec.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { Signal } from '@angular/core'; +import { createAgentRef } from '@threadplane/chat'; +import type { Equal, Expect } from '@threadplane/chat/testing'; +import { injectAgent } from './inject-agent'; +import type { LangGraphAgent } from './agent.types'; + +interface TripState { day: number; places: string[]; } +const TRIP = createAgentRef('trip'); + +declare function ctx(fn: () => T): T; + +// injectAgent(ref) is typed LangGraphAgent; state is Signal. +const typed = ctx(() => injectAgent(TRIP)); +type _agent = Expect>>; +type _state = Expect, TripState>>; +type _value = Expect, TripState>>; + +// no-arg form stays valid (default state). +const plain = ctx(() => injectAgent()); +type _plainState = Expect, Record>>; +``` +> Note: this references `@threadplane/chat/testing`. If `Equal`/`Expect` are not already re-exported there, import them via a relative path to the chat source `type-assert.ts` instead, or add the re-export in Task 10. Prefer adding the re-export in Task 10 and using the package path here. + +- [ ] **Step 3: Run the gate — expect FAIL** + +Run: `npx nx type-tests langgraph` +Expected: FAIL — `injectAgent(ref)` overload does not exist; `LangGraphAgent` `state` not yet typed off `T`. + +- [ ] **Step 4: Make `LangGraphAgent` surface typed state** + +In `libs/langgraph/src/lib/agent.types.ts`, change the declaration: +```ts +export interface LangGraphAgent + extends AgentWithHistory { +``` +(`AgentWithHistory` now flows `T` into the inherited `state: Signal`. Import `AgentWithHistory` from `@threadplane/chat` if not already.) + +- [ ] **Step 5: Add the ref overloads to `provideAgent`** + +In `libs/langgraph/src/lib/agent.provider.ts`, replace the single `provideAgent` signature with overloads (keep the existing body, route the token): +```ts +import type { AgentRef } from '@threadplane/chat'; + +export function provideAgent>( + ref: AgentRef, + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + refOrConfig: AgentRef | AgentConfig | (() => AgentConfig), + maybeConfig?: AgentConfig | (() => AgentConfig), +): Provider[] { + const ref = isAgentRef(refOrConfig) ? refOrConfig : undefined; + const configOrFactory = (ref ? maybeConfig : refOrConfig) as + | AgentConfig + | (() => AgentConfig); + + const resolveConfig = (): AgentConfig => + typeof configOrFactory === 'function' ? configOrFactory() : configOrFactory; + + const providers: Provider[] = [ + { provide: AGENT_CONFIG, useFactory: resolveConfig }, + { provide: AGENT, useFactory: agentFactory }, + ]; + // Also bind the typed ref token to the same singleton, when a ref is used. + if (ref) { + providers.push({ provide: ref.token, useExisting: AGENT }); + } + return providers; +} +``` +Extract the existing `useFactory` body into a named `agentFactory()` (same logic, currently inline) so both `AGENT` and the ref alias resolve one singleton. Add the guard: +```ts +function isAgentRef(x: unknown): x is AgentRef { + return typeof x === 'object' && x !== null && 'token' in x; +} +``` + +- [ ] **Step 6: Add the ref overload to `injectAgent`** + +`libs/langgraph/src/lib/inject-agent.ts`: +```ts +import { inject } from '@angular/core'; +import type { BagTemplate } from '@langchain/langgraph-sdk'; +import type { AgentRef } from '@threadplane/chat'; +import { AGENT } from './agent.provider'; +import type { LangGraphAgent } from './agent.types'; + +export function injectAgent(): LangGraphAgent; +export function injectAgent( + ref: AgentRef, +): LangGraphAgent; +export function injectAgent( + ref?: AgentRef, +): LangGraphAgent { + return inject(ref ? ref.token : AGENT) as LangGraphAgent; +} +``` +(The bare `injectAgent()` generic-only form is removed; callers either use the default or pass a ref.) + +- [ ] **Step 7: Run the gate + langgraph suite** + +Run: `npx nx type-tests langgraph && npx nx run-many -t test lint build --projects=langgraph --skip-nx-cache` +Expected: PASS. (If the type-spec import of `@threadplane/chat/testing` fails, complete Task 10's re-export first, then re-run.) + +- [ ] **Step 8: Commit** + +```bash +git add libs/langgraph/src/lib/agent.types.ts libs/langgraph/src/lib/agent.provider.ts \ + libs/langgraph/src/lib/inject-agent.ts libs/langgraph/tsconfig.type-tests.json \ + libs/langgraph/src/lib/inject-agent.type-spec.ts libs/langgraph/project.json +git commit -m "feat(langgraph): provideAgent/injectAgent AgentRef overloads — typed state through DI" +``` + +--- + +## Task 9: WS3 — AG-UI generic agent + ref overloads + +**Files:** +- Modify: `libs/ag-ui/src/lib/to-agent.ts` (`AgUiAgent`) +- Modify: `libs/ag-ui/src/lib/provide-agent.ts` + +- [ ] **Step 1: Genericize `AgUiAgent`** + +In `libs/ag-ui/src/lib/to-agent.ts`, change the `AgUiAgent` interface to extend the generic neutral contract: +```ts +export interface AgUiAgent> extends Agent { + // ...existing extensions (customEvents, clientTools, subagents) unchanged... +} +``` +`toAgent` keeps returning `AgUiAgent` (default state); no signature change needed beyond the interface. + +- [ ] **Step 2: Add ref overloads to `provideAgent` + `injectAgent`** + +In `libs/ag-ui/src/lib/provide-agent.ts`: +```ts +import type { AgentRef } from '@threadplane/chat'; + +export function provideAgent>( + ref: AgentRef, + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent( + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + refOrConfig: AgentRef | AgentConfig | (() => AgentConfig), + maybeConfig?: AgentConfig | (() => AgentConfig), +): Provider[] { + const ref = isAgentRef(refOrConfig) ? refOrConfig : undefined; + const configOrFactory = (ref ? maybeConfig : refOrConfig) as + | AgentConfig + | (() => AgentConfig); + const providers: Provider[] = [ + { provide: AGENT, useFactory: () => buildAgUiAgent(configOrFactory) }, + ]; + if (ref) providers.push({ provide: ref.token, useExisting: AGENT }); + return providers; +} + +export function injectAgent(): AgUiAgent; +export function injectAgent(ref: AgentRef): AgUiAgent; +export function injectAgent(ref?: AgentRef): AgUiAgent { + return inject(ref ? ref.token : AGENT) as AgUiAgent; +} + +function isAgentRef(x: unknown): x is AgentRef { + return typeof x === 'object' && x !== null && 'token' in x; +} +``` +Extract the existing HttpAgent-construction `useFactory` body into a `buildAgUiAgent(configOrFactory)` helper (same logic). + +- [ ] **Step 3: Run ag-ui suite + build** + +Run: `npx nx run-many -t test lint build --projects=ag-ui --skip-nx-cache` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add libs/ag-ui/src/lib/to-agent.ts libs/ag-ui/src/lib/provide-agent.ts +git commit -m "feat(ag-ui): genericize AgUiAgent + provideAgent/injectAgent AgentRef overloads" +``` + +--- + +## Task 10: WS4 — Prettify hovers + re-exports + +**Files:** +- Create: `libs/chat/src/lib/internals/prettify.ts` +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` (wrap inferred surfaces) +- Modify: `libs/chat/src/public-api.ts` +- Modify: `libs/chat/src/testing/` barrel (re-export `Equal`/`Expect`) + +- [ ] **Step 1: Add the internal `Prettify` helper** + +`libs/chat/src/lib/internals/prettify.ts`: +```ts +// SPDX-License-Identifier: MIT +/** + * @internal + * Identity mapped type that flattens an object type so editor quick-info shows + * the expanded shape instead of a raw conditional/mapped-type expression. + */ +export type Prettify = { [K in keyof T]: T[K] } & {}; +``` + +- [ ] **Step 2: Apply `Prettify` to the inferred public surfaces** + +In `libs/chat/src/lib/client-tools/component-inputs.ts`, wrap `ViewProps`: +```ts +import type { Prettify } from '../internals/prettify'; +export type ViewProps = Prettify>; +``` +(Leave `tool-def.ts` interfaces as-is — interfaces already hover cleanly; `Prettify` is for inferred *type aliases* like `ViewProps` and `ToolArgs`.) + +- [ ] **Step 3: Add re-exports to the chat public API** + +In `libs/chat/src/public-api.ts`, in the client-tools block: +```ts +export { tools, action, view, ask } from './lib/client-tools/tools'; +export type { ViewProps } from './lib/client-tools/component-inputs'; +// Schema typing helpers (so consumers don't reach into @threadplane/render): +export type { + StandardSchemaV1, + StandardSchemaInferInput, + StandardSchemaInferOutput, +} from '@threadplane/render'; +/** Inferred argument type for a schema (alias of StandardSchemaInferOutput). */ +export type ToolArgs = + import('@threadplane/render').StandardSchemaInferOutput; +export type { + FunctionToolDef, AnyFunctionToolDef, ViewToolDef, AskToolDef, ClientToolDef, ClientToolRegistry, +} from './lib/client-tools/tool-def'; +``` +And ensure the agent block re-exports the ref (it flows from the `./lib/agent` barrel updated in Task 7 — confirm `AgentRef`/`createAgentRef` appear in `public-api.ts`'s `export … from './lib/agent'`; add explicitly if the barrel is enumerated): +```ts +export { createAgentRef } from './lib/agent'; +export type { AgentRef } from './lib/agent'; +``` + +- [ ] **Step 4: Re-export the type-assert kit from `@threadplane/chat/testing`** + +Find the testing secondary entry point (`libs/chat/src/testing.ts` or `libs/chat/testing/`); add: +```ts +export type { Equal, Expect, Assignable } from './testing/type-assert'; +``` +> If the testing entry point imports vitest at module level, keep `type-assert` import as a `export type` only (it has no runtime), so it stays bundle-safe. + +- [ ] **Step 5: Verify chat builds, type-tests pass, langgraph type-spec resolves the testing import** + +Run: `npx nx run-many -t build type-tests --projects=chat --skip-nx-cache && npx nx type-tests langgraph` +Expected: PASS, including the langgraph `inject-agent.type-spec.ts` import of `@threadplane/chat/testing`. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/internals/prettify.ts libs/chat/src/lib/client-tools/component-inputs.ts \ + libs/chat/src/public-api.ts libs/chat/src/testing* +git commit -m "feat(chat): Prettify hovers + re-export StandardSchema*/ToolArgs/AgentRef + testing type-assert" +``` + +--- + +## Task 11: WS4 — JSDoc with `@example` on hero exports + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tools.ts` +- Modify: `libs/chat/src/lib/provide-chat.ts` +- Modify: `libs/render/src/lib/provide-render.ts`, `libs/render/src/lib/define-angular-registry.ts` +- (`createAgentRef`, `provideAgent`/`injectAgent` JSDoc already added in their tasks.) + +- [ ] **Step 1: Add `@param`/`@returns`/`@example` to `tools`/`action`/`view`/`ask`** + +Replace the terse one-line comments in `libs/chat/src/lib/client-tools/tools.ts`. Example for `action`: +```ts +/** + * Declare an async function tool the model can call; its resolved return value + * becomes the tool result shipped back to the model. + * + * @param description Natural-language description the model sees. + * @param schema Standard Schema (e.g. a Zod object) for the tool's arguments; + * the handler's argument type is inferred from it. + * @param handler Runs in the browser when the model calls the tool. + * @returns A {@link FunctionToolDef} for inclusion in {@link tools}. + * @example + * ```ts + * const move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay); + * const registry = tools({ move_stop: move }); + * ``` + */ +``` +Add equivalents for `view`, `ask` (mention the schema↔component input check + `ViewProps`) and `tools` (mention literal-key/per-key preservation). Keep each accurate to the signatures from Tasks 3 & 5. + +- [ ] **Step 2: Add JSDoc to `provideChat`, `provideRender`, `defineAngularRegistry`** + +Add a `@param`/`@returns`/`@example` block to each (they currently have none). Example for `defineAngularRegistry` mentions the `getEntry` accessor. + +- [ ] **Step 3: Verify hovers compile (build the libs)** + +Run: `npx nx run-many -t build lint --projects=chat,render --skip-nx-cache` +Expected: PASS (JSDoc is comments; this confirms nothing else regressed). + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/client-tools/tools.ts libs/chat/src/lib/provide-chat.ts \ + libs/render/src/lib/provide-render.ts libs/render/src/lib/define-angular-registry.ts +git commit -m "docs(chat,render): @param/@returns/@example JSDoc on hero exports" +``` + +--- + +## Task 12: Tier-1 forcing function — `cockpit/ag-ui/client-tools` + +**Files:** +- Modify: `cockpit/ag-ui/client-tools/angular/tsconfig.json` (flip `strict`) +- Modify: `cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts` +- Modify: `cockpit/ag-ui/client-tools/angular/src/app/app.config.ts` +- Modify: the view/ask component(s) under that app (input types to match schemas) + +- [ ] **Step 1: Flip the app to strict** + +In `cockpit/ag-ui/client-tools/angular/tsconfig.json`, set `"strict": true` (remove `"strict": false`). Leave `angularCompilerOptions` as-is unless step 4 requires `strictTemplates`. + +- [ ] **Step 2: Build — observe the failures (the forcing function in action)** + +Run: `npx nx build cockpit-ag-ui-client-tools-angular` +Expected: FAIL with `strictFunctionTypes`/typing errors on the `tools({...})` registry and any untyped `view`/`ask` components — this is the bug surfacing exactly as a real consumer would see it. + +- [ ] **Step 3: Migrate call sites to the typed APIs** + +Update `client-tools.component.ts` so `tools({...})` composes `action`/`view`/`ask` results directly (the typed registry now compiles). For each `view`/`ask`, ensure the paired component's signal inputs match the schema output (use `ViewProps` to type a component input bag where helpful). Fix any genuine schema↔component mismatches the compiler now flags. + +- [ ] **Step 4: Resolve remaining strict fallout** + +Fix any unrelated `strict` errors in this app's own code (implicit `any`, null checks). If the fallout is disproportionate and unrelated to the typed APIs, fall back to adding only `"strictFunctionTypes": true` to this app's tsconfig (the exact flag that masked the bug) and note it in the commit body. + +- [ ] **Step 5: Build green** + +Run: `npx nx build cockpit-ag-ui-client-tools-angular` +Expected: PASS under strict. + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/ag-ui/client-tools/angular +git commit -m "test(cockpit/ag-ui): client-tools app on strict:true + typed tools/view/ask (forcing function)" +``` + +--- + +## Task 13: Tier-1 forcing function — `cockpit/langgraph/client-tools` + +**Files:** +- Modify: `cockpit/langgraph/client-tools/angular/tsconfig.json` +- Modify: `cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts` +- Modify: `cockpit/langgraph/client-tools/angular/src/app/app.config.ts` (optionally `createAgentRef`) +- Modify: paired view/ask components + +- [ ] **Step 1: Flip to strict** + +Set `"strict": true` in `cockpit/langgraph/client-tools/angular/tsconfig.json`. + +- [ ] **Step 2: Build — observe failures** + +Run: `npx nx build cockpit-langgraph-client-tools-angular` +Expected: FAIL (same class of typed-registry/view-ask errors). + +- [ ] **Step 3: Migrate to typed APIs + a typed agent ref** + +Same migration as Task 12 for `tools/view/ask`. Additionally define a `createAgentRef()` for this app's state and use `provideAgent(ref, {...})` + `injectAgent(ref)` so the LangGraph typed-state path is exercised end to end. + +- [ ] **Step 4: Resolve remaining strict fallout (same fallback rule as Task 12 step 4).** + +- [ ] **Step 5: Build green** + +Run: `npx nx build cockpit-langgraph-client-tools-angular` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add cockpit/langgraph/client-tools/angular +git commit -m "test(cockpit/langgraph): client-tools app on strict:true + typed tools/view/ask + createAgentRef" +``` + +--- + +## Task 14: Tier-1 forcing function — `examples/ag-ui` (canonical itinerary) + +**Files:** +- Modify: `examples/ag-ui/angular/tsconfig.json` +- Modify: `examples/ag-ui/angular/src/app/client-tools.ts` +- Modify: `examples/ag-ui/angular/src/app/app.config.ts`, `shell/ag-ui-shell.component.ts` +- Modify: the itinerary view/ask components (e.g. `day_card`, `clear_day`) input types + +- [ ] **Step 1: Flip to strict** + +Set `"strict": true` in `examples/ag-ui/angular/tsconfig.json`. + +- [ ] **Step 2: Build — observe failures** + +Run: `npx nx build examples-ag-ui-angular` +Expected: FAIL (typed registry + view/ask linkage on the real itinerary tools). + +- [ ] **Step 3: Migrate the canonical demo** + +Update `client-tools.ts` to the typed registry; ensure `day_card`/`clear_day`/etc. components' signal inputs match their schemas (this is the demo that surfaced NG0950 — typed linkage now guards it at compile time). Use `createAgentRef` for the itinerary state and wire `provideAgent(ref, …)` / `injectAgent(ref)`. + +- [ ] **Step 4: Resolve remaining strict fallout (same fallback rule).** + +- [ ] **Step 5: Build green** + +Run: `npx nx build examples-ag-ui-angular` +Expected: PASS under strict. + +- [ ] **Step 6: Commit** + +```bash +git add examples/ag-ui/angular +git commit -m "test(examples/ag-ui): canonical itinerary on strict:true + typed client-tools + createAgentRef (forcing function)" +``` + +--- + +## Task 15: Tier-2 — build-net + representative `createAgentRef` migrations + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` (+ `app.config.ts`) +- Modify: one `cockpit/langgraph/*` app (e.g. `cockpit/langgraph/streaming/angular`) — `createAgentRef` +- Modify: one `cockpit/chat/*` app (e.g. `cockpit/chat/messages/angular`) — `createAgentRef` + +- [ ] **Step 1: Migrate `examples/chat` to a typed agent ref** + +In `examples/chat/angular`, define `createAgentRef()`, switch `provideAgent`/`injectAgent` to the ref form, and read typed `agent.state` somewhere to exercise the type. Keep the app's existing `strict` setting (Tier-2 proves non-breakage, not strict adoption). + +- [ ] **Step 2: Migrate one langgraph + one chat cockpit app likewise** (pick the simplest of each). + +- [ ] **Step 3: Build the migrated three** + +Run: `npx nx run-many -t build --projects=examples-chat-angular,cockpit-langgraph-streaming-angular,cockpit-chat-messages-angular --skip-nx-cache` +Expected: PASS. + +- [ ] **Step 4: Build-net — every affected app must still build green on existing settings** + +Run: `npx nx run-many -t build --projects=tag:scope:cockpit,examples-ag-ui-angular,examples-chat-angular --skip-nx-cache` +(If a `scope:cockpit` tag does not exist, enumerate the angular projects: `npx nx run-many -t build --all --skip-nx-cache` and confirm no regression vs. baseline.) +Expected: PASS across all `provideAgent`/`injectAgent` apps — proof the `Agent` default contains the genericization ripple. + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular cockpit/langgraph/streaming/angular cockpit/chat/messages/angular +git commit -m "test(examples,cockpit): representative createAgentRef migrations + build-net for Agent" +``` + +--- + +## Task 16: Tier-4 — full type-test + unit gate + +**Files:** none (verification + any straggler fixes) + +- [ ] **Step 1: Run both type-test gates** + +Run: `npx nx run-many -t type-tests --projects=chat,langgraph --skip-nx-cache` +Expected: PASS — all WS1/WS2/WS3 assertions and the `@ts-expect-error` negative tests hold under strict. + +- [ ] **Step 2: Run the full affected unit + lint + build suite** + +Run: `npx nx run-many -t test lint build --projects=chat,render,langgraph,ag-ui --skip-nx-cache` +Expected: PASS (0 lint errors; warnings pre-existing). + +- [ ] **Step 3: Commit any straggler fixes** (only if steps surfaced something). + +```bash +git commit -am "fix(dx): resolve straggler type/lint issues from the DX pass" || echo "nothing to commit" +``` + +--- + +## Task 17: Tier-3 — live-LLM smoke (manual gate) + +**Files:** none + +- [ ] **Step 1: Serve each forcing-function app with a real key and drive it** + +Per the live-LLM-smoke gate, for each of `cockpit/ag-ui/client-tools`, `cockpit/langgraph/client-tools`, `examples/ag-ui` (itinerary), and `examples/chat`: serve with a real API key, drive the tool flows in Chrome, and confirm zero console errors and that view/ask tool flows complete (type changes are erased at runtime, but the migrations touched real call sites). Free ports between runs; do not run e2e while a live serve holds the same ports. + +- [ ] **Step 2: Record the smoke result** in the PR description (which apps, what flows, console-clean confirmation). + +> This is a human/controller gate — do not mark complete on green unit/type tests alone. + +--- + +## Task 18: Final review + PR + +- [ ] **Step 1: Final whole-implementation code review** (subagent-driven-development's terminal reviewer): correctness of the variance formulation, the `view`/`ask` linkage (incl. the error-tuple branch), the DI ref overloads in both adapters, no internal-type leaks in the built `.d.ts`, JSDoc accuracy. + +- [ ] **Step 2: Push the branch and open the PR** + +```bash +git push -u origin claude/typescript-dx-pass +gh pr create --base main --title "feat: TypeScript DX pass — strict-safe tools/view/ask + typed agent DI" --body-file <(...) +``` +PR body summarizes the five workstreams, the four-tier example validation (incl. the strict-flipped forcing-function apps and the live-smoke result), and links the spec. + +- [ ] **Step 3: Enable auto-merge** (`gh pr merge --squash --auto`) and finish via `superpowers:finishing-a-development-branch`. + +--- + +## Self-Review (against the spec) + +**Spec coverage:** +- WS1 (tools/action strict fix + per-key/literal keys + return type) → Tasks 2, 3. ✓ +- WS2 (view/ask linkage + ViewProps) → Tasks 4, 5. ✓ +- WS3 (Agent + AgentWithHistory + createAgentRef + both adapters) → Tasks 6, 7, 8, 9. ✓ +- WS4 (Prettify, re-exports incl. StandardSchema*/ToolArgs/AgentRef, JSDoc) → Tasks 10, 11. ✓ +- WS5 (strict type-test gate) → Task 1 (harness) + per-WS type-specs + Task 16 (full gate). ✓ +- Tier 1 (migrate + strict on 3 apps) → Tasks 12, 13, 14. ✓ +- Tier 2 (build-net + representative refs) → Task 15. ✓ +- Tier 3 (live smoke) → Task 17. ✓ +- Tier 4 (type-spec gate) → Tasks 1/16. ✓ + +**Type consistency:** `FunctionToolDef`, `AnyFunctionToolDef`, `ViewToolDef`, `AskToolDef`, `ClientToolDef`, `AcceptComponent`, `ComponentInputs`, `ViewProps`, `ToolArgs`, `Agent`, `AgentWithHistory`, `AgentRef`, `createAgentRef`, `LangGraphAgent`, `AgUiAgent` — names used consistently across tasks. + +**Placeholder scan:** no TBD/TODO; every code step shows real code. The one soft spot (`@threadplane/chat/testing` re-export ordering) is explicitly sequenced: Task 10 adds the re-export; Task 8's note says complete Task 10 first if the import fails. Task 15 step 4 gives a concrete fallback if no `scope:cockpit` tag exists. diff --git a/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md b/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md index 9437873b..4a90e540 100644 --- a/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md +++ b/docs/superpowers/specs/2026-06-18-typescript-dx-pass-design.md @@ -34,27 +34,37 @@ Fixes findings #1, #2, #6. ```ts // libs/chat/src/lib/client-tools/tool-def.ts -/** Variance-safe base every tool-def kind extends. Handler param is the bottom - * type so a handler that demands a *specific* arg type stays assignable to it - * under strictFunctionTypes. Used only as the constraint bound in tools(). */ -export interface AnyClientToolDef { - kind: 'function' | 'view' | 'ask'; - description: string; - schema: StandardSchemaV1; - // function kind: - handler?: (args: never) => unknown | Promise; - // view/ask kind: - component?: Type; +/** Precise authored type — what action() returns. Carries the schema (S) and + * the handler's resolved return type (R). */ +export interface FunctionToolDef { + readonly kind: 'function'; + readonly description: string; + readonly schema: S; + readonly handler: (args: StandardSchemaInferOutput) => R | Promise; } -export interface FunctionToolDef { - kind: 'function'; - description: string; - schema: S; - handler: (args: StandardSchemaInferOutput) => R | Promise; +/** Bivariant union member used only for storage/iteration. The handler param is + * `any` (NOT `never`): `any` is the one param type that is simultaneously + * (a) a supertype any precise `FunctionToolDef` is assignable to under + * `strictFunctionTypes`, and (b) callable by internal code that has already + * narrowed by `kind` and parsed runtime args. A `never` param satisfies (a) + * but breaks (b) — the coordinator/executor could no longer call `handler()`. */ +export interface AnyFunctionToolDef { + readonly kind: 'function'; + readonly description: string; + readonly schema: StandardSchemaV1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariance escape hatch; see above + readonly handler: (args: any) => unknown | Promise; } + +export type ClientToolDef = + | AnyFunctionToolDef + | ViewToolDef + | AskToolDef; ``` +Verified under `strict: true`: a precise `FunctionToolDef` is assignable to `ClientToolDef`; `tools({...})` preserves per-key types and literal keys; and an internal consumer that does `if (def.kind === 'function') def.handler(parsedArgs)` type-checks. + ```ts // libs/chat/src/lib/client-tools/tools.ts @@ -68,12 +78,12 @@ export function action( /** Collect named client tools into a frozen registry (the key is the tool name). * Generic + const over the map so per-tool types and literal keys survive. */ -export function tools>(map: M): Readonly { +export function tools>(map: M): Readonly { return Object.freeze({ ...map }); } ``` -Result: `tools({ move: action('…', moveSchema, h) })` compiles under `strict: true`; the returned registry's `move` key keeps its `FunctionToolDef` type and the key union is `'move'`. +Result: `tools({ move: action('…', moveSchema, h) })` compiles under `strict: true`; the returned registry's `move` key keeps its `FunctionToolDef` type and the key union is `'move'`. The `ClientToolRegistry` alias becomes `Readonly>` (unchanged shape), and `createClientToolsCoordinator`/`toClientToolSpecs` keep accepting `Record` — the bivariant member is what lets a typed registry pass that boundary too. ### WS2 — `view()` / `ask()` schema↔component linkage (strict-but-flexible) @@ -123,7 +133,7 @@ export function ask( export type ViewProps = StandardSchemaInferOutput; ``` -`ViewToolDef` / `AskToolDef` carry both the schema and the component type. They remain assignable to `AnyClientToolDef` (component widened to `Type` in the base), so `tools({...})` accepts them. +`ViewToolDef` / `AskToolDef` carry both the schema and the component type. Their `component: Type` is assignable to the union members' `component: Type` (covariant construct return), so they remain assignable to `ClientToolDef` and `tools({...})` accepts them. **Known limitation (intentional):** "every *required* input must be covered by the schema" is not enforceable at compile time — Angular does not brand required inputs in the type system (`input.required()` and `input(default)` are both `InputSignal`). The shipped runtime schema-readiness gate (holds the fallback until streamed props validate) is the backstop for that case. Compile-time blocks structural mismatches; runtime blocks incomplete/missing props. @@ -194,7 +204,7 @@ Whole existing unit suites stay green; one example app is built to confirm lib s ## Files touched - `libs/chat/src/lib/client-tools/tools.ts` — generic `tools`, `action`, `view`, `ask`, `ViewProps`. -- `libs/chat/src/lib/client-tools/tool-def.ts` — `AnyClientToolDef`, `FunctionToolDef`, `ViewToolDef`, `AskToolDef`. +- `libs/chat/src/lib/client-tools/tool-def.ts` — `AnyFunctionToolDef` (bivariant union member), `FunctionToolDef`, `ViewToolDef`, `AskToolDef`, the `ClientToolDef` union. - `libs/chat/src/lib/client-tools/component-inputs.ts` *(new)* — `ComponentInputs`, `CompatibleProps`, `AcceptComponent`. - `libs/chat/src/lib/agent/agent.ts` — `Agent>`. - `libs/chat/src/lib/agent/agent-ref.ts` *(new)* — `AgentRef`, `createAgentRef`. @@ -236,7 +246,7 @@ A workstream is "done" only when its Tier-1 forcing-function apps compile under - **`Agent` genericization (WS3) is the highest-surface change.** The `= Record` default contains the ripple, but it touches the shared contract and both adapters. The Tier-2 build net (all ~35 `provideAgent`/`injectAgent` apps building green on their existing settings) is the explicit proof that the default truly contains it. Accepted as in-scope (comprehensive spec); WS3 is the natural split point if it later needs to be de-risked into its own PR. - **Flipping Tier-1 apps to full `strict: true` may surface unrelated example-code issues** (implicit `any`, etc.) beyond the typed-API changes. These are fixed in the example code as part of the migration; if the fallout in any one app proves disproportionate, fall back to enabling `strictFunctionTypes: true` alone (the exact flag that masked the bug) for that app and note it. - **`const` type parameter on `tools()`** requires TypeScript ≥ 5.0; the repo is well past that. -- **The `view`/`ask` `never`/error-tuple constraint** must keep `ViewToolDef`/`AskToolDef` assignable to `AnyClientToolDef` so `tools({...})` still accepts views/asks — covered by a WS5 type-test. +- **The `view`/`ask` error-tuple constraint** must keep `ViewToolDef`/`AskToolDef` assignable to `ClientToolDef` so `tools({...})` still accepts views/asks — covered by a WS5 type-test. ## Explicitly NOT in scope From f180eeb667aa15dcbd3d3dad717493c34e1be263 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:11:07 -0700 Subject: [PATCH 04/24] test(chat): strict type-test harness (Equal/Expect + tsconfig + nx target) Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/project.json | 6 ++++++ .../src/lib/client-tools/tools.type-spec.ts | 5 +++++ libs/chat/src/testing/type-assert.ts | 12 ++++++++++++ libs/chat/tsconfig.type-tests.json | 18 ++++++++++++++++++ 4 files changed, 41 insertions(+) create mode 100644 libs/chat/src/lib/client-tools/tools.type-spec.ts create mode 100644 libs/chat/src/testing/type-assert.ts create mode 100644 libs/chat/tsconfig.type-tests.json diff --git a/libs/chat/project.json b/libs/chat/project.json index e04ed3cf..437e9d10 100644 --- a/libs/chat/project.json +++ b/libs/chat/project.json @@ -47,6 +47,12 @@ "options": { "configFile": "libs/chat/vite.config.mts" } + }, + "type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsc --noEmit -p libs/chat/tsconfig.type-tests.json" + } } } } diff --git a/libs/chat/src/lib/client-tools/tools.type-spec.ts b/libs/chat/src/lib/client-tools/tools.type-spec.ts new file mode 100644 index 00000000..625488c3 --- /dev/null +++ b/libs/chat/src/lib/client-tools/tools.type-spec.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +import type { Equal, Expect } from '../../testing/type-assert'; + +// Harness smoke — proves the type-test pipeline runs. +type _smoke = Expect>; diff --git a/libs/chat/src/testing/type-assert.ts b/libs/chat/src/testing/type-assert.ts new file mode 100644 index 00000000..8ab273f3 --- /dev/null +++ b/libs/chat/src/testing/type-assert.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +/** Compile-time assertion helpers for *.type-spec.ts files (no runtime, no vitest dep). */ + +/** Exact-type equality (invariant). `Equal` is `true` iff A and B are identical. */ +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +/** Passes only when `T` is exactly `true`; otherwise a compile error. */ +export type Expect = T; + +/** True if `A` is assignable to `B`. */ +export type Assignable = A extends B ? true : false; diff --git a/libs/chat/tsconfig.type-tests.json b/libs/chat/tsconfig.type-tests.json new file mode 100644 index 00000000..47e1cd41 --- /dev/null +++ b/libs/chat/tsconfig.type-tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [], + "inlineSources": false, + "declaration": false, + "declarationMap": false, + "noUnusedLocals": false + }, + "files": [], + "include": ["src/**/*.type-spec.ts"], + "exclude": ["src/**/*.spec.ts"], + "references": [] +} From cc0301ae83d2bb84cb47dc96ab8a703487dbcac6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:16:53 -0700 Subject: [PATCH 05/24] feat(chat): variance-safe ClientToolDef (FunctionToolDef + bivariant AnyFunctionToolDef) Adds a precise `FunctionToolDef` for authoring (carries schema + return type) and a bivariant `AnyFunctionToolDef` as the union member in `ClientToolDef`, so typed `action()` results are assignable into the registry under `strictFunctionTypes`. Updates `executeFunctionTool` to accept `AnyFunctionToolDef`; exports `AnyFunctionToolDef` from the public index. Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/lib/client-tools/execute.ts | 4 +- libs/chat/src/lib/client-tools/index.ts | 2 +- libs/chat/src/lib/client-tools/tool-def.ts | 43 +++++++++++++++------- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/libs/chat/src/lib/client-tools/execute.ts b/libs/chat/src/lib/client-tools/execute.ts index 4f9907e7..14f592c6 100644 --- a/libs/chat/src/lib/client-tools/execute.ts +++ b/libs/chat/src/lib/client-tools/execute.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import type { StandardSchemaV1 } from '@threadplane/render'; -import type { FunctionToolDef } from './tool-def'; +import type { AnyFunctionToolDef } from './tool-def'; import type { ClientToolResult } from './client-tools-capability'; /** Validate raw model args against a Standard Schema. */ @@ -20,7 +20,7 @@ export async function validateArgs( /** Validate args, run the handler, and normalize the outcome to a ClientToolResult. */ export async function executeFunctionTool( - def: FunctionToolDef, + def: AnyFunctionToolDef, rawArgs: unknown, ): Promise { const v = await validateArgs(def.schema, rawArgs); diff --git a/libs/chat/src/lib/client-tools/index.ts b/libs/chat/src/lib/client-tools/index.ts index 24af7f41..05b46764 100644 --- a/libs/chat/src/lib/client-tools/index.ts +++ b/libs/chat/src/lib/client-tools/index.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT export { action, view, ask, tools } from './tools'; export { deriveJsonSchema } from './to-json-schema'; -export type { ClientToolDef, FunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry } from './tool-def'; +export type { ClientToolDef, FunctionToolDef, AnyFunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry } from './tool-def'; export type { ClientToolSpec } from './to-json-schema'; export type { ClientToolsCapability, ClientToolResult } from './client-tools-capability'; export { validateArgs, executeFunctionTool } from './execute'; diff --git a/libs/chat/src/lib/client-tools/tool-def.ts b/libs/chat/src/lib/client-tools/tool-def.ts index ac0aefda..3e3a66d3 100644 --- a/libs/chat/src/lib/client-tools/tool-def.ts +++ b/libs/chat/src/lib/client-tools/tool-def.ts @@ -2,32 +2,47 @@ import type { Type } from '@angular/core'; import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; -/** A client tool the model can call; executed in the browser. */ -export type ClientToolDef = - | FunctionToolDef - | ViewToolDef - | AskToolDef; - -export interface FunctionToolDef { +/** Precise authored function tool — what `action()` returns. Carries the schema + * `S` and the handler's resolved return type `R`. */ +export interface FunctionToolDef { readonly kind: 'function'; readonly description: string; readonly schema: S; - readonly handler: (args: StandardSchemaInferOutput) => unknown | Promise; + readonly handler: (args: StandardSchemaInferOutput) => R | Promise; } -export interface ViewToolDef { - readonly kind: 'view'; +/** Bivariant union member used only for registry storage/iteration. The handler + * param is `any` (NOT `never`): `any` is simultaneously a supertype any precise + * `FunctionToolDef` is assignable to under `strictFunctionTypes`, AND + * callable by internal code that has narrowed by `kind` and parsed runtime args. + * A `never` param would satisfy the former but break the latter. */ +export interface AnyFunctionToolDef { + readonly kind: 'function'; readonly description: string; readonly schema: StandardSchemaV1; - readonly component: Type; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariance escape hatch; see note above + readonly handler: (args: any) => unknown | Promise; } -export interface AskToolDef { +export interface ViewToolDef { + readonly kind: 'view'; + readonly description: string; + readonly schema: S; + readonly component: Type; +} + +export interface AskToolDef { readonly kind: 'ask'; readonly description: string; - readonly schema: StandardSchemaV1; - readonly component: Type; + readonly schema: S; + readonly component: Type; } +/** A client tool the model can call; executed in the browser. */ +export type ClientToolDef = + | AnyFunctionToolDef + | ViewToolDef + | AskToolDef; + /** A frozen, name-keyed registry of client tools. */ export type ClientToolRegistry = Readonly>; From bd639e4b77c1a1d30cc7b9e0a85946bfc638d1f8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:17:00 -0700 Subject: [PATCH 06/24] =?UTF-8?q?feat(chat):=20generic=20tools()/action()?= =?UTF-8?q?=20=E2=80=94=20strict-safe=20registry=20with=20per-key=20+=20li?= =?UTF-8?q?teral-key=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `action()` now infers the handler return type R and carries it in `FunctionToolDef`. `tools()` is generic over the map so literal keys and per-entry tool types survive into the registry type. WS1 type-spec in tools.type-spec.ts covers arg-inference, return-type inference, union assignability, literal keys, and per-key type preservation; all assertions pass under strict + strictFunctionTypes. Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/lib/client-tools/tools.ts | 13 ++++++----- .../src/lib/client-tools/tools.type-spec.ts | 22 +++++++++++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/libs/chat/src/lib/client-tools/tools.ts b/libs/chat/src/lib/client-tools/tools.ts index 665cbf55..c78c14c2 100644 --- a/libs/chat/src/lib/client-tools/tools.ts +++ b/libs/chat/src/lib/client-tools/tools.ts @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT import type { Type } from '@angular/core'; import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; -import type { ClientToolDef, ClientToolRegistry, FunctionToolDef } from './tool-def'; +import type { ClientToolDef, FunctionToolDef } from './tool-def'; /** Async function tool — its resolved return value becomes the tool result. */ -export function action( +export function action( description: string, schema: S, - handler: (args: StandardSchemaInferOutput) => unknown | Promise, -): FunctionToolDef { + handler: (args: StandardSchemaInferOutput) => R | Promise, +): FunctionToolDef { return { kind: 'function', description, schema, handler }; } @@ -22,7 +22,8 @@ export function ask(description: string, schema: StandardSchemaV1, component: Ty return { kind: 'ask', description, schema, component }; } -/** Collect named client tools into a frozen registry (the key is the tool name). */ -export function tools(map: Record): ClientToolRegistry { +/** Collect named client tools into a frozen registry (the key is the tool name). + * Generic + `const` over the map so per-tool types and literal keys survive. */ +export function tools>(map: M): Readonly { return Object.freeze({ ...map }); } diff --git a/libs/chat/src/lib/client-tools/tools.type-spec.ts b/libs/chat/src/lib/client-tools/tools.type-spec.ts index 625488c3..2719738c 100644 --- a/libs/chat/src/lib/client-tools/tools.type-spec.ts +++ b/libs/chat/src/lib/client-tools/tools.type-spec.ts @@ -1,5 +1,23 @@ // SPDX-License-Identifier: MIT import type { Equal, Expect } from '../../testing/type-assert'; +import type { FunctionToolDef, ClientToolDef } from './tool-def'; +import { action, tools } from './tools'; +import { z } from 'zod/v4'; -// Harness smoke — proves the type-test pipeline runs. -type _smoke = Expect>; +const moveSchema = z.object({ fromDay: z.number(), placeId: z.string() }); + +// action() infers handler arg from the schema output and carries the return type R. +const moveAction = action('Move a stop', moveSchema, (a) => a.fromDay + 1); +type _argInfer = Expect[0], { fromDay: number; placeId: string }>>; +type _retInfer = Expect>>; + +// A precise FunctionToolDef must be assignable into the bivariant union. +const _u: ClientToolDef = moveAction; + +// tools() preserves per-key tool types AND literal keys under strict. +const registry = tools({ + move_stop: moveAction, + note: action('Note', z.object({ text: z.string() }), (a) => a.text), +}); +type _keys = Expect>; +type _perKey = Expect>>; From 4d918c8293f8c1363c6f26fc26229d71983ac2be Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:26:01 -0700 Subject: [PATCH 07/24] feat(chat): ComponentInputs/AcceptComponent type machinery for view/ask linkage Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/client-tools/component-inputs.ts | 57 +++++++++++++++++++ .../lib/client-tools/view-ask.type-spec.ts | 43 ++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 libs/chat/src/lib/client-tools/component-inputs.ts create mode 100644 libs/chat/src/lib/client-tools/view-ask.type-spec.ts diff --git a/libs/chat/src/lib/client-tools/component-inputs.ts b/libs/chat/src/lib/client-tools/component-inputs.ts new file mode 100644 index 00000000..3de48f9e --- /dev/null +++ b/libs/chat/src/lib/client-tools/component-inputs.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +import type { InputSignal, InputSignalWithTransform, Type } from '@angular/core'; +import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; + +/** Value type carried by an Angular signal input. */ +type InputValue

= + P extends InputSignal ? T : + P extends InputSignalWithTransform ? T : + never; + +/** A component instance's declared signal inputs, as a plain prop bag. + * + * Implementation note: Angular's `InputSignal` and `InputSignalWithTransform` + * use `InputSignalNode` in an invariant position, making them invariant in their + * type parameters under TypeScript's structural system. `InputSignal` + * does NOT extend `InputSignal`. We therefore use `any` in the filter + * predicate — `any` is a two-way assignability wildcard that correctly subsumes + * all concrete instantiations without widening the extracted value type. */ +export type ComponentInputs = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- required; see note above + [K in keyof C as C[K] extends InputSignal | InputSignalWithTransform + ? K + : never]: InputValue; +}; + +/** STRICT: every prop the schema PRODUCES must be a declared input with an + * assignable type. FLEXIBLE: the component may declare extra inputs the schema + * doesn't fill. A schema key absent from `Inputs` maps to `never`, so its + * (non-never) value fails assignment and the error pins to that prop. */ +export type CompatibleProps = { + [K in keyof Out]: K extends keyof Inputs ? Inputs[K] : never; +}; + +/** The accepted `component` parameter type for `view`/`ask`: the real component + * `Type` when the schema output is compatible, else a labelled error tuple + * that surfaces both shapes in the compiler message. + * + * Implementation note: `C extends ...` (distributive over C) lets TypeScript + * infer `C` from the `Type` arm first, then verify the constraint. A bare + * conditional on the param type blocks inference when C appears only on the + * right-hand side of the inner `extends`. */ +export type AcceptComponent = + C extends ( + StandardSchemaInferOutput extends CompatibleProps, ComponentInputs> + ? C + : never + ) + ? Type + : readonly [ + 'Schema output is not assignable to this component\'s inputs', + StandardSchemaInferOutput, + ComponentInputs, + ]; + +/** Reverse helper: derive a component's input prop types FROM a schema, so a + * component authored straight from the schema is guaranteed compatible. */ +export type ViewProps = StandardSchemaInferOutput; diff --git a/libs/chat/src/lib/client-tools/view-ask.type-spec.ts b/libs/chat/src/lib/client-tools/view-ask.type-spec.ts new file mode 100644 index 00000000..5d742230 --- /dev/null +++ b/libs/chat/src/lib/client-tools/view-ask.type-spec.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +import { Component, input } from '@angular/core'; +import { z } from 'zod/v4'; +import type { ClientToolDef, ViewToolDef } from './tool-def'; +import { view, ask, tools } from './tools'; + +@Component({ template: '' }) +class DayCardComponent { + day = input.required(); + places = input([]); // optional (default) + highlight = input(false); // extra input NOT in schema +} + +@Component({ template: '' }) +class UnrelatedComponent { + title = input.required(); +} + +const daySchema = z.object({ day: z.number(), places: z.array(z.string()) }); + +// ✅ good — schema output keys ⊆ inputs, compatible types; extra `highlight` allowed. +const dayView = view('Show a day', daySchema, DayCardComponent); + +// the view tool stays assignable to the registry union and through tools(). +const _u: ClientToolDef = dayView; +const _reg = tools({ day_card: view('Show a day', daySchema, DayCardComponent) }); + +// the result carries the component type. +const _carries: ViewToolDef = dayView; + +// ❌ typo prop the component can't receive. +const typoSchema = z.object({ dayz: z.number() }); +// @ts-expect-error `dayz` is not an input of DayCardComponent +const _bad1 = view('typo', typoSchema, DayCardComponent); + +// ❌ type mismatch (day: string vs input number). +const wrongType = z.object({ day: z.string() }); +// @ts-expect-error day: string not assignable to input day: number +const _bad2 = view('wrong type', wrongType, DayCardComponent); + +// ❌ unrelated component. +// @ts-expect-error schema output {day, places} has no matching inputs on UnrelatedComponent +const _bad3 = ask('unrelated', daySchema, UnrelatedComponent); From f4abf1873e03ea6872722e91becff21ada0ced03 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:26:04 -0700 Subject: [PATCH 08/24] =?UTF-8?q?feat(chat):=20generic=20view()/ask()=20?= =?UTF-8?q?=E2=80=94=20strict-but-flexible=20schema<->component=20input=20?= =?UTF-8?q?linkage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/lib/client-tools/tools.ts | 26 ++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/libs/chat/src/lib/client-tools/tools.ts b/libs/chat/src/lib/client-tools/tools.ts index c78c14c2..e1c0b0ec 100644 --- a/libs/chat/src/lib/client-tools/tools.ts +++ b/libs/chat/src/lib/client-tools/tools.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT import type { Type } from '@angular/core'; import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; -import type { ClientToolDef, FunctionToolDef } from './tool-def'; +import type { ClientToolDef, FunctionToolDef, ViewToolDef, AskToolDef } from './tool-def'; +import type { AcceptComponent } from './component-inputs'; +export type { ViewProps } from './component-inputs'; /** Async function tool — its resolved return value becomes the tool result. */ export function action( @@ -12,14 +14,24 @@ export function action( return { kind: 'function', description, schema, handler }; } -/** Render-only component tool — the model fills its props; auto-acknowledged. */ -export function view(description: string, schema: StandardSchemaV1, component: Type): ClientToolDef { - return { kind: 'view', description, schema, component }; +/** Render-only component tool — the model fills its props; auto-acknowledged. + * The component's signal inputs are checked against the schema's output type. */ +export function view( + description: string, + schema: S, + component: AcceptComponent, +): ViewToolDef { + return { kind: 'view', description, schema, component: component as Type }; } -/** Interactive (HITL) component tool — the value it emits becomes the result. */ -export function ask(description: string, schema: StandardSchemaV1, component: Type): ClientToolDef { - return { kind: 'ask', description, schema, component }; +/** Interactive (HITL) component tool — the value it emits becomes the result. + * The component's signal inputs are checked against the schema's output type. */ +export function ask( + description: string, + schema: S, + component: AcceptComponent, +): AskToolDef { + return { kind: 'ask', description, schema, component: component as Type }; } /** Collect named client tools into a frozen registry (the key is the tool name). From 62f19390b5b442c9fd6515c3f98b17ba5269f1f1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:33:42 -0700 Subject: [PATCH 09/24] feat(chat): genericize Agent + AgentWithHistory (default Record) Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/lib/agent/agent-with-history.ts | 2 +- libs/chat/src/lib/agent/agent.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/chat/src/lib/agent/agent-with-history.ts b/libs/chat/src/lib/agent/agent-with-history.ts index 0fc764af..be44d7a1 100644 --- a/libs/chat/src/lib/agent/agent-with-history.ts +++ b/libs/chat/src/lib/agent/agent-with-history.ts @@ -10,7 +10,7 @@ import type { AgentCheckpoint } from './agent-checkpoint'; * implement this. Pure request/response runtimes that don't have checkpoints * should implement plain Agent. */ -export interface AgentWithHistory extends Agent { +export interface AgentWithHistory> extends Agent { history: Signal; /** * Optional reactive map of `messageId → checkpointId`, computed by diff --git a/libs/chat/src/lib/agent/agent.ts b/libs/chat/src/lib/agent/agent.ts index bd2a9e33..d6c4fffc 100644 --- a/libs/chat/src/lib/agent/agent.ts +++ b/libs/chat/src/lib/agent/agent.ts @@ -23,14 +23,14 @@ import type { ClientToolsCapability } from '../client-tools/client-tools-capabil * Invariant: state lives on signals; `events$` carries only things that are * not derivable from signals. */ -export interface Agent { +export interface Agent> { // Core state messages: Signal; status: Signal; isLoading: Signal; error: Signal; toolCalls: Signal; - state: Signal>; + state: Signal; // Actions submit: (input: AgentSubmitInput, opts?: AgentSubmitOptions) => Promise; From e76134c0614c42306f076a93d475552a6d18e697 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:33:47 -0700 Subject: [PATCH 10/24] feat(chat): createAgentRef typed DI handle (AgentRef) + public-api export Co-Authored-By: Claude Sonnet 4.6 --- libs/chat/src/lib/agent/agent-ref.ts | 27 +++++++++++++++++++ .../chat/src/lib/agent/agent-ref.type-spec.ts | 14 ++++++++++ libs/chat/src/lib/agent/index.ts | 2 ++ libs/chat/src/public-api.ts | 2 ++ 4 files changed, 45 insertions(+) create mode 100644 libs/chat/src/lib/agent/agent-ref.ts create mode 100644 libs/chat/src/lib/agent/agent-ref.type-spec.ts diff --git a/libs/chat/src/lib/agent/agent-ref.ts b/libs/chat/src/lib/agent/agent-ref.ts new file mode 100644 index 00000000..ae7111a8 --- /dev/null +++ b/libs/chat/src/lib/agent/agent-ref.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +import { InjectionToken } from '@angular/core'; +import type { Agent } from './agent'; + +/** A typed handle that threads a state shape through Angular DI from + * `provideAgent(ref, …)` to `injectAgent(ref)` without per-call-site + * restatement of the generic. */ +export interface AgentRef { + readonly token: InjectionToken>; +} + +/** + * Create a typed agent handle. + * + * @param debugName Optional name shown in Angular DI error messages. + * @returns An {@link AgentRef} carrying a state-typed `InjectionToken`. + * @example + * ```ts + * interface TripState { day: number; places: string[]; } + * export const TRIP = createAgentRef('trip'); + * // app.config.ts: provideAgent(TRIP, { assistantId: 'trip' }) + * // component: const agent = injectAgent(TRIP); // LangGraphAgent + * ``` + */ +export function createAgentRef(debugName?: string): AgentRef { + return { token: new InjectionToken>(debugName ?? 'ThreadplaneAgent') }; +} diff --git a/libs/chat/src/lib/agent/agent-ref.type-spec.ts b/libs/chat/src/lib/agent/agent-ref.type-spec.ts new file mode 100644 index 00000000..89865893 --- /dev/null +++ b/libs/chat/src/lib/agent/agent-ref.type-spec.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +import type { InjectionToken } from '@angular/core'; +import type { Equal, Expect } from '../../testing/type-assert'; +import type { Agent } from './agent'; +import { createAgentRef, type AgentRef } from './agent-ref'; + +interface TripState { day: number; places: string[]; } + +const trip = createAgentRef('trip'); +type _refTyped = Expect>>; +type _tokenTyped = Expect>>>; + +// default state when no param. +type _default = Expect>>>; diff --git a/libs/chat/src/lib/agent/index.ts b/libs/chat/src/lib/agent/index.ts index 81f528f5..d1d05dff 100644 --- a/libs/chat/src/lib/agent/index.ts +++ b/libs/chat/src/lib/agent/index.ts @@ -16,6 +16,8 @@ export type { } from './agent-event'; export type { AgentCheckpoint } from './agent-checkpoint'; export type { AgentWithHistory } from './agent-with-history'; +export type { AgentRef } from './agent-ref'; +export { createAgentRef } from './agent-ref'; export type { AgentRuntimeTelemetryEvent, AgentRuntimeTelemetryPayload, diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 2ca4e56a..79c8ee74 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -34,7 +34,9 @@ export { isAssistantMessage, isToolMessage, isSystemMessage, + createAgentRef, } from './lib/agent'; +export type { AgentRef } from './lib/agent'; // Primitives export { ChatMessageListComponent, getMessageType } from './lib/primitives/chat-message-list/chat-message-list.component'; From b5ad6ad22a3f74a8909ea8e9b0b6ece002146f73 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:39:52 -0700 Subject: [PATCH 11/24] =?UTF-8?q?feat(langgraph):=20provideAgent/injectAge?= =?UTF-8?q?nt=20AgentRef=20overloads=20=E2=80=94=20typed=20state=20through?= =?UTF-8?q?=20DI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/langgraph/project.json | 4 + libs/langgraph/src/lib/agent.fn.ts | 2 +- libs/langgraph/src/lib/agent.provider.ts | 91 +++++++++++++------ libs/langgraph/src/lib/agent.types.ts | 2 +- libs/langgraph/src/lib/inject-agent.ts | 24 ++++- .../src/lib/inject-agent.type-spec.ts | 18 ++++ libs/langgraph/src/testing/type-assert.ts | 5 + libs/langgraph/tsconfig.type-tests.json | 18 ++++ 8 files changed, 127 insertions(+), 37 deletions(-) create mode 100644 libs/langgraph/src/lib/inject-agent.type-spec.ts create mode 100644 libs/langgraph/src/testing/type-assert.ts create mode 100644 libs/langgraph/tsconfig.type-tests.json diff --git a/libs/langgraph/project.json b/libs/langgraph/project.json index 66d0d7e6..e2d1ea50 100644 --- a/libs/langgraph/project.json +++ b/libs/langgraph/project.json @@ -47,6 +47,10 @@ "options": { "configFile": "libs/langgraph/vite.config.mts" } + }, + "type-tests": { + "executor": "nx:run-commands", + "options": { "command": "npx tsc --noEmit -p libs/langgraph/tsconfig.type-tests.json" } } } } diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 26a3d8c6..3959271a 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -415,7 +415,7 @@ export function agent< isLoading, error: errorSig, toolCalls: toolCallsNeutral, - state: stateNeutral, + state: stateNeutral as Signal, interrupt: interruptNeutral, subagents: subagentsNeutral, events$, diff --git a/libs/langgraph/src/lib/agent.provider.ts b/libs/langgraph/src/lib/agent.provider.ts index 884cef35..c0b763c6 100644 --- a/libs/langgraph/src/lib/agent.provider.ts +++ b/libs/langgraph/src/lib/agent.provider.ts @@ -2,7 +2,7 @@ import { InjectionToken, inject, type Provider, type Signal } from '@angular/core'; import type { BaseMessage } from '@langchain/core/messages'; import type { BagTemplate } from '@langchain/langgraph-sdk'; -import type { AgentRuntimeTelemetrySink } from '@threadplane/chat'; +import type { AgentRef, AgentRuntimeTelemetrySink } from '@threadplane/chat'; import { agent } from './agent.fn'; import type { AgentTransport, @@ -61,6 +61,36 @@ export const AGENT_CONFIG = new InjectionToken('AGENT_CONFIG'); */ export const AGENT = new InjectionToken('AGENT'); +/** @internal — shared factory that reads AGENT_CONFIG and constructs the singleton. */ +function agentFactory(): LangGraphAgent { + // useFactory runs in an injection context, so the legacy `agent()` + // factory's `inject(DestroyRef)` calls work. + const config = inject(AGENT_CONFIG) as AgentConfig; + if (config.assistantId === undefined) { + throw new Error( + 'provideAgent: `assistantId` is required to construct the AGENT singleton.', + ); + } + return agent({ + assistantId: config.assistantId, + ...(config.apiUrl !== undefined ? { apiUrl: config.apiUrl } : {}), + ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), + ...(config.onThreadId !== undefined ? { onThreadId: config.onThreadId } : {}), + ...(config.initialValues !== undefined ? { initialValues: config.initialValues } : {}), + ...(config.throttle !== undefined ? { throttle: config.throttle } : {}), + ...(config.toMessage !== undefined ? { toMessage: config.toMessage } : {}), + ...(config.transport !== undefined ? { transport: config.transport } : {}), + ...(config.clientOptions !== undefined ? { clientOptions: config.clientOptions } : {}), + ...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}), + ...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}), + ...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}), + }); +} + +function isAgentRef(x: unknown): x is AgentRef { + return typeof x === 'object' && x !== null && 'token' in x; +} + /** * Wire the LangGraph adapter into Angular's dependency injection. * @@ -86,46 +116,47 @@ export const AGENT = new InjectionToken('AGENT'); * }), * ]; * ``` + * + * **Typed state via AgentRef.** Pass a typed ref as the first argument to flow + * the state shape from `provideAgent` to `injectAgent` without repeating the + * generic at every call site: + * + * ```ts + * export const TRIP = createAgentRef('trip'); + * // app.config.ts: + * providers: [provideAgent(TRIP, { assistantId: 'trip-graph' })] + * // component: + * const agent = injectAgent(TRIP); // LangGraphAgent + * ``` */ +export function provideAgent>( + ref: AgentRef, + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; export function provideAgent>( configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + refOrConfig: AgentRef | AgentConfig | (() => AgentConfig), + maybeConfig?: AgentConfig | (() => AgentConfig), ): Provider[] { + const ref = isAgentRef(refOrConfig) ? refOrConfig : undefined; + const configOrFactory = (ref ? maybeConfig : refOrConfig) as + | AgentConfig + | (() => AgentConfig); + // Resolve the factory (if any) lazily, inside the injection context of the // AGENT_CONFIG useFactory below — never at decoration time. const resolveConfig = (): AgentConfig => - typeof configOrFactory === 'function' ? configOrFactory() : configOrFactory; + typeof configOrFactory === 'function' ? (configOrFactory as () => AgentConfig)() : configOrFactory; - return [ + const providers: Provider[] = [ // AGENT_CONFIG resolves the config once (running the factory in an // injection context if a factory was passed). AGENT reads the resolved // config from here, so the factory is invoked exactly once. { provide: AGENT_CONFIG, useFactory: resolveConfig }, - { - provide: AGENT, - useFactory: () => { - // useFactory runs in an injection context, so the legacy `agent()` - // factory's `inject(DestroyRef)` calls work. - const config = inject(AGENT_CONFIG) as AgentConfig; - if (config.assistantId === undefined) { - throw new Error( - 'provideAgent: `assistantId` is required to construct the AGENT singleton.', - ); - } - return agent({ - assistantId: config.assistantId, - ...(config.apiUrl !== undefined ? { apiUrl: config.apiUrl } : {}), - ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), - ...(config.onThreadId !== undefined ? { onThreadId: config.onThreadId } : {}), - ...(config.initialValues !== undefined ? { initialValues: config.initialValues } : {}), - ...(config.throttle !== undefined ? { throttle: config.throttle } : {}), - ...(config.toMessage !== undefined ? { toMessage: config.toMessage } : {}), - ...(config.transport !== undefined ? { transport: config.transport } : {}), - ...(config.clientOptions !== undefined ? { clientOptions: config.clientOptions } : {}), - ...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}), - ...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}), - ...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}), - }); - }, - }, + { provide: AGENT, useFactory: agentFactory }, ]; + if (ref) providers.push({ provide: ref.token, useExisting: AGENT }); + return providers; } diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index 675db8c8..fadbc455 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -312,7 +312,7 @@ export interface SubagentStreamRef { * `langGraph` to avoid collision with the runtime-neutral names. */ export interface LangGraphAgent - extends AgentWithHistory { + extends AgentWithHistory { // ── Raw LangGraph signals ──────────────────────────────────────────────── /** Raw LangChain BaseMessage list. Use `messages` for chat rendering. */ diff --git a/libs/langgraph/src/lib/inject-agent.ts b/libs/langgraph/src/lib/inject-agent.ts index 3c6eabf9..8cd83d1a 100644 --- a/libs/langgraph/src/lib/inject-agent.ts +++ b/libs/langgraph/src/lib/inject-agent.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { inject } from '@angular/core'; import type { BagTemplate } from '@langchain/langgraph-sdk'; +import type { AgentRef } from '@threadplane/chat'; import { AGENT } from './agent.provider'; import type { LangGraphAgent } from './agent.types'; @@ -12,10 +13,23 @@ import type { LangGraphAgent } from './agent.types'; * singleton scoped to the injector that called `provideAgent()` — re-provide * in a child component's `providers: []` to scope a different agent to that * subtree (Angular's hierarchical DI handles the rest). + * + * **Typed state via AgentRef.** Pass the same ref that was supplied to + * `provideAgent(ref, …)` to carry the state type through DI without repeating + * the generic at every call site: + * + * ```ts + * const agent = injectAgent(TRIP); // LangGraphAgent + * ``` + * + * The no-arg form defaults to `LangGraphAgent>`. */ -export function injectAgent< - T = Record, - ResolvedBag extends BagTemplate = BagTemplate, ->(): LangGraphAgent { - return inject(AGENT) as LangGraphAgent; +export function injectAgent(): LangGraphAgent>; +export function injectAgent( + ref: AgentRef, +): LangGraphAgent; +export function injectAgent, ResolvedBag extends BagTemplate = BagTemplate>( + ref?: AgentRef, +): LangGraphAgent { + return inject(ref ? ref.token : AGENT) as LangGraphAgent; } diff --git a/libs/langgraph/src/lib/inject-agent.type-spec.ts b/libs/langgraph/src/lib/inject-agent.type-spec.ts new file mode 100644 index 00000000..a05c5086 --- /dev/null +++ b/libs/langgraph/src/lib/inject-agent.type-spec.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; +import type { Equal, Expect } from '../testing/type-assert'; +import { injectAgent } from './inject-agent'; + +interface TripState { day: number; places: string[]; } +const TRIP = createAgentRef('trip'); + +declare function ctx(fn: () => T): T; + +// injectAgent(ref) is typed LangGraphAgent; state and value are Signal. +const typed = ctx(() => injectAgent(TRIP)); +type _agentState = Expect, TripState>>; +type _agentValue = Expect, TripState>>; + +// no-arg form stays valid (default state). +const plain = ctx(() => injectAgent()); +type _plainState = Expect, Record>>; diff --git a/libs/langgraph/src/testing/type-assert.ts b/libs/langgraph/src/testing/type-assert.ts new file mode 100644 index 00000000..b355134b --- /dev/null +++ b/libs/langgraph/src/testing/type-assert.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +/** Compile-time assertion helpers for *.type-spec.ts files (no runtime). */ +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +export type Expect = T; diff --git a/libs/langgraph/tsconfig.type-tests.json b/libs/langgraph/tsconfig.type-tests.json new file mode 100644 index 00000000..47e1cd41 --- /dev/null +++ b/libs/langgraph/tsconfig.type-tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [], + "inlineSources": false, + "declaration": false, + "declarationMap": false, + "noUnusedLocals": false + }, + "files": [], + "include": ["src/**/*.type-spec.ts"], + "exclude": ["src/**/*.spec.ts"], + "references": [] +} From df123c78b62771c2149f74321601d281d8ac3000 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:47:48 -0700 Subject: [PATCH 12/24] feat(ag-ui): genericize AgUiAgent + provideAgent/injectAgent AgentRef overloads Co-Authored-By: Claude Sonnet 4.5 --- libs/ag-ui/project.json | 6 ++ libs/ag-ui/src/lib/provide-agent.ts | 81 ++++++++++++++----- libs/ag-ui/src/lib/provide-agent.type-spec.ts | 16 ++++ libs/ag-ui/src/lib/to-agent.ts | 2 +- libs/ag-ui/src/testing/type-assert.ts | 4 + libs/ag-ui/tsconfig.type-tests.json | 18 +++++ 6 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 libs/ag-ui/src/lib/provide-agent.type-spec.ts create mode 100644 libs/ag-ui/src/testing/type-assert.ts create mode 100644 libs/ag-ui/tsconfig.type-tests.json diff --git a/libs/ag-ui/project.json b/libs/ag-ui/project.json index ee184acd..c000bb57 100644 --- a/libs/ag-ui/project.json +++ b/libs/ag-ui/project.json @@ -47,6 +47,12 @@ "options": { "configFile": "libs/ag-ui/vite.config.mts" } + }, + "type-tests": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsc --noEmit -p libs/ag-ui/tsconfig.type-tests.json" + } } } } diff --git a/libs/ag-ui/src/lib/provide-agent.ts b/libs/ag-ui/src/lib/provide-agent.ts index c55da900..f9ffcd5e 100644 --- a/libs/ag-ui/src/lib/provide-agent.ts +++ b/libs/ag-ui/src/lib/provide-agent.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { InjectionToken, inject, type Provider } from '@angular/core'; import { HttpAgent } from '@ag-ui/client'; -import type { AgentRuntimeTelemetrySink } from '@threadplane/chat'; +import type { AgentRef, AgentRuntimeTelemetrySink } from '@threadplane/chat'; import { toAgent, type AgUiAgent } from './to-agent'; /** @@ -28,6 +28,25 @@ export interface AgentConfig { */ export const AGENT = new InjectionToken('AGENT'); +/** @internal — shared factory for building an AgUiAgent from an AgentConfig or factory. */ +function buildAgUiAgent(configOrFactory: AgentConfig | (() => AgentConfig)): AgUiAgent { + // useFactory runs in an injection context, so a config factory may + // call inject() to read runtime/DI state. + const config = + typeof configOrFactory === 'function' ? configOrFactory() : configOrFactory; + const source = new HttpAgent({ + url: config.url, + ...(config.agentId !== undefined ? { agentId: config.agentId } : {}), + ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), + ...(config.headers !== undefined ? { headers: config.headers } : {}), + }); + return toAgent(source, { telemetry: config.telemetry }); +} + +function isAgentRef(x: unknown): x is AgentRef { + return typeof x === 'object' && x !== null && 'token' in x; +} + /** * Provides an Agent instance wired through HttpAgent and toAgent. * Constructs an HttpAgent from config and wraps it in the runtime-neutral @@ -38,28 +57,38 @@ export const AGENT = new InjectionToken('AGENT'); * config is known up front. Pass a `() => AgentConfig` factory when the config * depends on runtime/DI state — the factory runs inside an Angular injection * context, so it may call `inject()` to read services or route params. + * + * **Typed state via AgentRef.** Pass a typed ref as the first argument to flow + * the state shape from `provideAgent` to `injectAgent` without repeating the + * generic at every call site: + * + * ```ts + * interface TripState { day: number; places: string[]; } + * export const TRIP = createAgentRef('trip'); + * // app.config.ts: + * providers: [provideAgent(TRIP, { url: 'http://localhost:8000/agent' })] + * // component: + * const agent = injectAgent(TRIP); // AgUiAgent + * ``` */ +export function provideAgent>( + ref: AgentRef, + configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; export function provideAgent( configOrFactory: AgentConfig | (() => AgentConfig), +): Provider[]; +export function provideAgent>( + refOrConfig: AgentRef | AgentConfig | (() => AgentConfig), + maybeConfig?: AgentConfig | (() => AgentConfig), ): Provider[] { - return [ - { - provide: AGENT, - useFactory: () => { - // useFactory runs in an injection context, so a config factory may - // call inject() to read runtime/DI state. - const config = - typeof configOrFactory === 'function' ? configOrFactory() : configOrFactory; - const source = new HttpAgent({ - url: config.url, - ...(config.agentId !== undefined ? { agentId: config.agentId } : {}), - ...(config.threadId !== undefined ? { threadId: config.threadId } : {}), - ...(config.headers !== undefined ? { headers: config.headers } : {}), - }); - return toAgent(source, { telemetry: config.telemetry }); - }, - }, + const ref = isAgentRef(refOrConfig) ? refOrConfig : undefined; + const configOrFactory = (ref ? maybeConfig : refOrConfig) as AgentConfig | (() => AgentConfig); + const providers: Provider[] = [ + { provide: AGENT, useFactory: () => buildAgUiAgent(configOrFactory) }, ]; + if (ref) providers.push({ provide: ref.token, useExisting: AGENT }); + return providers; } /** @@ -70,7 +99,19 @@ export function provideAgent( * Returns an `AgUiAgent` — the runtime-neutral `Agent` contract plus the * AG-UI-specific `customEvents` signal — so `customEvents` is reachable * directly, without casting. + * + * **Typed state via AgentRef.** Pass the same ref that was supplied to + * `provideAgent(ref, …)` to carry the state type through DI without repeating + * the generic at every call site: + * + * ```ts + * const agent = injectAgent(TRIP); // AgUiAgent + * ``` + * + * The no-arg form defaults to `AgUiAgent>`. */ -export function injectAgent(): AgUiAgent { - return inject(AGENT); +export function injectAgent(): AgUiAgent; +export function injectAgent(ref: AgentRef): AgUiAgent; +export function injectAgent(ref?: AgentRef): AgUiAgent { + return inject(ref ? ref.token : AGENT) as AgUiAgent; } diff --git a/libs/ag-ui/src/lib/provide-agent.type-spec.ts b/libs/ag-ui/src/lib/provide-agent.type-spec.ts new file mode 100644 index 00000000..6d89e3e7 --- /dev/null +++ b/libs/ag-ui/src/lib/provide-agent.type-spec.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; +import type { Equal, Expect } from '../testing/type-assert'; +import { injectAgent } from './provide-agent'; +import type { AgUiAgent } from './to-agent'; + +interface TripState { day: number; places: string[]; } +const TRIP = createAgentRef('trip'); +declare function ctx(fn: () => T): T; + +const typed = ctx(() => injectAgent(TRIP)); +type _state = Expect, TripState>>; +type _isAgUi = Expect>>; + +const plain = ctx(() => injectAgent()); +type _plainState = Expect, Record>>; diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index 6d03fe56..5bfdb57a 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -53,7 +53,7 @@ function agentRuntimeTelemetryErrorClass(error: unknown): string { * live a2ui streaming) and the optional `clientTools` capability. * Mirrors langgraph's LangGraphAgent extension. */ -export interface AgUiAgent extends Agent { +export interface AgUiAgent> extends Agent { customEvents: Signal; clientTools: ClientToolsCapability; /** Subagent activities (activityType==='subagent') projected to the neutral diff --git a/libs/ag-ui/src/testing/type-assert.ts b/libs/ag-ui/src/testing/type-assert.ts new file mode 100644 index 00000000..63a48b0b --- /dev/null +++ b/libs/ag-ui/src/testing/type-assert.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +export type Expect = T; diff --git a/libs/ag-ui/tsconfig.type-tests.json b/libs/ag-ui/tsconfig.type-tests.json new file mode 100644 index 00000000..47e1cd41 --- /dev/null +++ b/libs/ag-ui/tsconfig.type-tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "strictFunctionTypes": true, + "skipLibCheck": true, + "types": [], + "inlineSources": false, + "declaration": false, + "declarationMap": false, + "noUnusedLocals": false + }, + "files": [], + "include": ["src/**/*.type-spec.ts"], + "exclude": ["src/**/*.spec.ts"], + "references": [] +} From 58134130d4b52410c1b1063c98a4d61885c4e7c5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:55:30 -0700 Subject: [PATCH 13/24] feat(chat): Prettify hovers + re-export StandardSchema*/ToolArgs/ViewProps/AnyFunctionToolDef Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/chat/src/lib/client-tools/component-inputs.ts | 3 ++- libs/chat/src/lib/client-tools/tool-def.ts | 4 +++- libs/chat/src/lib/internals/prettify.ts | 7 +++++++ libs/chat/src/public-api.ts | 5 ++++- 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 libs/chat/src/lib/internals/prettify.ts diff --git a/libs/chat/src/lib/client-tools/component-inputs.ts b/libs/chat/src/lib/client-tools/component-inputs.ts index 3de48f9e..53d2e1b0 100644 --- a/libs/chat/src/lib/client-tools/component-inputs.ts +++ b/libs/chat/src/lib/client-tools/component-inputs.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import type { InputSignal, InputSignalWithTransform, Type } from '@angular/core'; import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; +import type { Prettify } from '../internals/prettify'; /** Value type carried by an Angular signal input. */ type InputValue

= @@ -54,4 +55,4 @@ export type AcceptComponent = /** Reverse helper: derive a component's input prop types FROM a schema, so a * component authored straight from the schema is guaranteed compatible. */ -export type ViewProps = StandardSchemaInferOutput; +export type ViewProps = Prettify>; diff --git a/libs/chat/src/lib/client-tools/tool-def.ts b/libs/chat/src/lib/client-tools/tool-def.ts index 3e3a66d3..c1d51378 100644 --- a/libs/chat/src/lib/client-tools/tool-def.ts +++ b/libs/chat/src/lib/client-tools/tool-def.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT import type { Type } from '@angular/core'; -import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; +import type { StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput } from '@threadplane/render'; + +export type { StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput }; /** Precise authored function tool — what `action()` returns. Carries the schema * `S` and the handler's resolved return type `R`. */ diff --git a/libs/chat/src/lib/internals/prettify.ts b/libs/chat/src/lib/internals/prettify.ts new file mode 100644 index 00000000..e47760de --- /dev/null +++ b/libs/chat/src/lib/internals/prettify.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +/** + * @internal + * Identity mapped type that flattens an object type so editor quick-info shows + * the expanded shape instead of a raw conditional/mapped-type expression. + */ +export type Prettify = { [K in keyof T]: T[K] } & {}; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 79c8ee74..cfeb7341 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -210,7 +210,10 @@ export { isPathRef, isLiteralString, isLiteralNumber, isLiteralBoolean } from '@ // Client tools (declaration API — tools/action/view/ask + JSON-schema derivation) export { tools, action, view, ask } from './lib/client-tools/tools'; export { deriveJsonSchema } from './lib/client-tools/to-json-schema'; -export type { ClientToolDef, FunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry } from './lib/client-tools/tool-def'; +export type { ClientToolDef, AnyFunctionToolDef, FunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry, StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput } from './lib/client-tools/tool-def'; +export type { ViewProps } from './lib/client-tools/component-inputs'; +/** Inferred argument type for a schema (alias of StandardSchemaInferOutput). */ +export type ToolArgs = import('./lib/client-tools/tool-def').StandardSchemaInferOutput; export type { ClientToolSpec } from './lib/client-tools/to-json-schema'; export type { ClientToolsCapability, ClientToolResult } from './lib/client-tools/client-tools-capability'; export { validateArgs, executeFunctionTool } from './lib/client-tools/execute'; From 02821d30959d55d6c2abe43d1d8b6ca95b7ecde9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 10:55:33 -0700 Subject: [PATCH 14/24] docs(chat,render): @param/@returns/@example JSDoc on hero exports Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/chat/src/lib/client-tools/tools.ts | 107 ++++++++++++++++-- libs/chat/src/lib/provide-chat.ts | 40 +++++++ .../render/src/lib/define-angular-registry.ts | 48 ++++++++ libs/render/src/lib/provide-render.ts | 35 ++++++ 4 files changed, 223 insertions(+), 7 deletions(-) diff --git a/libs/chat/src/lib/client-tools/tools.ts b/libs/chat/src/lib/client-tools/tools.ts index e1c0b0ec..0cbc24e6 100644 --- a/libs/chat/src/lib/client-tools/tools.ts +++ b/libs/chat/src/lib/client-tools/tools.ts @@ -5,7 +5,22 @@ import type { ClientToolDef, FunctionToolDef, ViewToolDef, AskToolDef } from './ import type { AcceptComponent } from './component-inputs'; export type { ViewProps } from './component-inputs'; -/** Async function tool — its resolved return value becomes the tool result. */ +/** + * Declare an async function tool the model can call; its resolved return value + * becomes the tool result shipped back to the model. + * + * @param description Natural-language description the model sees. + * @param schema Standard Schema (e.g. a Zod object) for the arguments; the + * handler's argument type is inferred from it. + * @param handler Runs in the browser when the model calls the tool; its return + * type `R` is carried on the resulting {@link FunctionToolDef}. + * @returns A {@link FunctionToolDef} for inclusion in {@link tools}. + * @example + * ```ts + * const move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay); + * const registry = tools({ move_stop: move }); + * ``` + */ export function action( description: string, schema: S, @@ -14,8 +29,36 @@ export function action( return { kind: 'function', description, schema, handler }; } -/** Render-only component tool — the model fills its props; auto-acknowledged. - * The component's signal inputs are checked against the schema's output type. */ +/** + * Render-only component tool — the model fills the component's props from the + * schema's output; the tool call is auto-acknowledged once the component mounts. + * + * The component's signal inputs are checked against the schema output type + * (strict-but-flexible: every schema key must be a declared input with an + * assignable type; the component may declare extra inputs the schema doesn't fill). + * Author the component with `ViewProps` as the input type set to + * guarantee the shapes stay aligned. + * + * @param description Natural-language description the model sees. + * @param schema Standard Schema defining the props the model must supply. + * @param component Angular component whose signal inputs must be compatible with + * the schema output. A type-level error is reported here when they diverge. + * @returns A {@link ViewToolDef} for inclusion in {@link tools}. + * @example + * ```ts + * const schema = z.object({ label: z.string(), day: z.number() }); + * type Inputs = ViewProps; // { label: string; day: number } + * + * \@Component({ ... }) + * class DayCardComponent { + * label = input.required(); + * day = input.required(); + * } + * + * const dayCard = view('Show a day card', schema, DayCardComponent); + * const registry = tools({ day_card: dayCard }); + * ``` + */ export function view( description: string, schema: S, @@ -24,8 +67,38 @@ export function view( return { kind: 'view', description, schema, component: component as Type }; } -/** Interactive (HITL) component tool — the value it emits becomes the result. - * The component's signal inputs are checked against the schema's output type. */ +/** + * Interactive (human-in-the-loop) component tool — the model fills the + * component's props from the schema's output; the value the component emits + * back to the framework becomes the tool result sent to the model. + * + * The component's signal inputs are checked against the schema output type + * (strict-but-flexible: every schema key must be a declared input with an + * assignable type; the component may declare extra inputs the schema doesn't + * fill). Author the component with `ViewProps` to derive input + * prop types directly from the schema. + * + * @param description Natural-language description the model sees. + * @param schema Standard Schema defining the props the model must supply. + * @param component Angular component whose signal inputs must be compatible with + * the schema output. A type-level error is reported here when they diverge. + * @returns An {@link AskToolDef} for inclusion in {@link tools}. + * @example + * ```ts + * const schema = z.object({ question: z.string(), options: z.array(z.string()) }); + * type Inputs = ViewProps; + * + * \@Component({ ... }) + * class ChoiceCardComponent { + * question = input.required(); + * options = input.required(); + * // Emits the chosen option back to the model. + * } + * + * const choice = ask('Ask the user to choose', schema, ChoiceCardComponent); + * const registry = tools({ pick_option: choice }); + * ``` + */ export function ask( description: string, schema: S, @@ -34,8 +107,28 @@ export function ask( return { kind: 'ask', description, schema, component: component as Type }; } -/** Collect named client tools into a frozen registry (the key is the tool name). - * Generic + `const` over the map so per-tool types and literal keys survive. */ +/** + * Collect named client tools into a frozen, name-keyed registry. + * + * The overload is generic over the entire map (`const M`) so that each tool's + * precise type ({@link FunctionToolDef}``, {@link ViewToolDef}``, or + * {@link AskToolDef}``) and every literal key are preserved in the + * {@link ClientToolRegistry} passed to `provideChat`. This lets downstream + * consumers look up individual tools without losing generic information. + * + * @param map An object literal mapping tool names to tool definitions created + * by {@link action}, {@link view}, or {@link ask}. + * @returns A frozen `Readonly` where `M` is the exact inferred map shape. + * @example + * ```ts + * const move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay); + * const dayCard = view('Show a day card', z.object({ label: z.string() }), DayCardComponent); + * + * const registry = tools({ move_stop: move, day_card: dayCard }); + * // registry.move_stop is FunctionToolDef<...> + * // registry.day_card is ViewToolDef<...> + * ``` + */ export function tools>(map: M): Readonly { return Object.freeze({ ...map }); } diff --git a/libs/chat/src/lib/provide-chat.ts b/libs/chat/src/lib/provide-chat.ts index ee5c3a52..228fc39b 100644 --- a/libs/chat/src/lib/provide-chat.ts +++ b/libs/chat/src/lib/provide-chat.ts @@ -33,6 +33,46 @@ export interface ChatConfig { export const CHAT_CONFIG = new InjectionToken('CHAT_CONFIG'); +/** + * Bootstrap `@threadplane/chat` in an Angular application or standalone + * component tree. + * + * Call this once inside `bootstrapApplication` (or the `providers` array of a + * root `ApplicationConfig`). It registers the shared {@link ChatConfig} token + * so every chat component in the tree can read the render registry, avatar + * label, and assistant display name without explicit prop threading. + * + * A license check is fired asynchronously on every call (it never throws; a + * watermark is shown in non-commercial builds when no valid token is supplied). + * + * @param config Options bag that controls the chat feature set: + * - `renderRegistry` — shared {@link AngularRegistry} wiring tool-view + * components to their names; pass the value returned by + * `defineAngularRegistry` from `\@threadplane/render`. + * - `avatarLabel` — short label shown in the AI avatar bubble (default `"A"`). + * - `assistantName` — display name shown above assistant messages + * (default `"Assistant"`). + * - `license` — signed token from threadplane.ai; omit in development. + * @returns An `EnvironmentProviders` value suitable for the `providers` array + * of `bootstrapApplication` or `ApplicationConfig`. + * @example + * ```ts + * // main.ts + * import { bootstrapApplication } from '@angular/platform-browser'; + * import { provideChat } from '@threadplane/chat'; + * import { defineAngularRegistry, provideRender } from '@threadplane/render'; + * import { DayCardComponent } from './day-card.component'; + * + * const registry = defineAngularRegistry({ day_card: DayCardComponent }); + * + * bootstrapApplication(AppComponent, { + * providers: [ + * provideChat({ renderRegistry: registry, avatarLabel: 'AI' }), + * provideRender({ registry }), + * ], + * }); + * ``` + */ export function provideChat(config: ChatConfig) { void runLicenseCheck({ package: PACKAGE_NAME, diff --git a/libs/render/src/lib/define-angular-registry.ts b/libs/render/src/lib/define-angular-registry.ts index b671333c..19434091 100644 --- a/libs/render/src/lib/define-angular-registry.ts +++ b/libs/render/src/lib/define-angular-registry.ts @@ -17,6 +17,54 @@ function normalize(entry: Type | RenderViewEntry): NormalizedEntry { }; } +/** + * Build an {@link AngularRegistry} from a plain object mapping tool-call names + * to Angular components (or fully specified {@link RenderViewEntry} objects). + * + * The returned registry is consumed by both `provideRender` (to drive + * dynamic component rendering) and `provideChat` (via `renderRegistry`) so + * that a single `defineAngularRegistry` call wires both layers. + * + * **Entry forms** + * - Bare `Type` — the component is paired with the built-in + * `DefaultFallbackComponent` while its props are still streaming. + * - `RenderViewEntry` object — lets you supply a custom `fallback` component, + * an optional Standard Schema (`schema`) used as a mount-readiness gate, and + * an optional `description` for model-facing tool registration. + * + * **Registry accessor** + * The returned object exposes a single `getEntry(name: string)` accessor that + * returns the fully-normalized {@link NormalizedEntry} (component + fallback + + * optional schema + optional description) or `undefined` when the name is not + * registered. Use `names()` to enumerate all registered names. + * + * @param componentMap Object whose keys are tool-call names and whose values + * are either bare Angular component classes or {@link RenderViewEntry} objects. + * @returns An {@link AngularRegistry} with `getEntry` and `names` accessors. + * @example + * ```ts + * import { defineAngularRegistry } from '@threadplane/render'; + * import { DayCardComponent } from './day-card.component'; + * import { LoadingSpinnerComponent } from './loading-spinner.component'; + * import { z } from 'zod'; + * + * export const registry = defineAngularRegistry({ + * // Bare component — uses DefaultFallbackComponent while streaming. + * summary_card: SummaryCardComponent, + * + * // Full entry — custom fallback + schema-gated mounting. + * day_card: { + * component: DayCardComponent, + * fallback: LoadingSpinnerComponent, + * schema: z.object({ label: z.string(), day: z.number() }), + * description: 'Renders a single itinerary day card.', + * }, + * }); + * + * // Look up a registered entry at runtime: + * const entry = registry.getEntry('day_card'); // NormalizedEntry | undefined + * ``` + */ export function defineAngularRegistry(componentMap: RegistryInput): AngularRegistry { const map = new Map(); for (const [name, entry] of Object.entries(componentMap)) { diff --git a/libs/render/src/lib/provide-render.ts b/libs/render/src/lib/provide-render.ts index d33f37b3..2a4ac707 100644 --- a/libs/render/src/lib/provide-render.ts +++ b/libs/render/src/lib/provide-render.ts @@ -6,6 +6,41 @@ import { RenderLifecycleService } from './render-lifecycle.service'; export const RENDER_CONFIG = new InjectionToken('RENDER_CONFIG'); +/** + * Bootstrap `@threadplane/render` in an Angular application or standalone + * component tree. + * + * Registers the shared {@link RenderConfig} token and the internal + * `RenderLifecycleService` that coordinates mount/unmount events across + * dynamically rendered components. Call this once alongside `provideChat` in + * `bootstrapApplication` (or the root `ApplicationConfig`). + * + * @param config Options bag that controls the render feature set: + * - `registry` — component registry returned by {@link defineAngularRegistry}; + * maps tool-call names to Angular components. + * - `store` — optional `StateStore` for `\@json-render/core` state binding. + * - `functions` — optional map of computed functions available inside specs. + * - `handlers` — optional map of event handlers triggered by spec actions. + * @returns An `EnvironmentProviders` value suitable for the `providers` array + * of `bootstrapApplication` or `ApplicationConfig`. + * @example + * ```ts + * // main.ts + * import { bootstrapApplication } from '@angular/platform-browser'; + * import { defineAngularRegistry, provideRender } from '@threadplane/render'; + * import { provideChat } from '@threadplane/chat'; + * import { DayCardComponent } from './day-card.component'; + * + * const registry = defineAngularRegistry({ day_card: DayCardComponent }); + * + * bootstrapApplication(AppComponent, { + * providers: [ + * provideRender({ registry }), + * provideChat({ renderRegistry: registry }), + * ], + * }); + * ``` + */ export function provideRender(config: RenderConfig) { return makeEnvironmentProviders([ { provide: RENDER_CONFIG, useValue: config }, From 45b27c26702e69b60eaa8fc04548119f81c4d9ac Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 11:02:40 -0700 Subject: [PATCH 15/24] =?UTF-8?q?docs(langgraph):=20migrate=20guides=20to?= =?UTF-8?q?=20createAgentRef/injectAgent(ref)=20=E2=80=94=20drop=20removed?= =?UTF-8?q?=20bare-generic=20injectAgent()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../docs/langgraph/api/inject-agent.mdx | 49 +++++++++++++++++-- .../langgraph/concepts/agent-architecture.mdx | 10 ++-- .../langgraph/concepts/angular-signals.mdx | 17 ++++--- .../langgraph/concepts/langgraph-basics.mdx | 8 +-- .../langgraph/concepts/state-management.mdx | 35 ++++++++----- .../docs/langgraph/guides/deployment.mdx | 4 +- .../docs/langgraph/guides/interrupts.mdx | 22 +++++++-- .../content/docs/langgraph/guides/memory.mdx | 16 +++--- .../docs/langgraph/guides/subgraphs.mdx | 42 +++++++++++----- .../docs/langgraph/guides/time-travel.mdx | 14 ++++-- 10 files changed, 161 insertions(+), 56 deletions(-) diff --git a/apps/website/content/docs/langgraph/api/inject-agent.mdx b/apps/website/content/docs/langgraph/api/inject-agent.mdx index cded775c..6c0cdbc2 100644 --- a/apps/website/content/docs/langgraph/api/inject-agent.mdx +++ b/apps/website/content/docs/langgraph/api/inject-agent.mdx @@ -4,12 +4,16 @@ Call it in an Angular injection context, usually as a component field initializer. The returned object exposes Angular Signals for UI state and async methods for user actions. -Configuration is supplied globally via `provideAgent({...})` — `injectAgent()` itself takes no arguments. For typed state, supply its generics: +## Overloads + +### No-arg form ```ts -injectAgent(): LangGraphAgent +injectAgent(): LangGraphAgent ``` +Returns the default-typed agent. `state()` and `value()` are `Record`. Use this form when your component does not need to read typed state fields. + ```ts import { injectAgent } from '@threadplane/langgraph'; @@ -18,18 +22,55 @@ readonly chat = injectAgent(); await this.chat.submit({ message: 'Hello' }); ``` +### Typed ref form (recommended for typed state) + +```ts +injectAgent(ref: AgentRef): LangGraphAgent +``` + +Pass a typed ref handle created with `createAgentRef()` from `@threadplane/chat`. Returns a `LangGraphAgent` where `state()` and `value()` are typed as `T`. The same ref is passed to `provideAgent()` to bind the configuration. + +```ts +import { createAgentRef } from '@threadplane/chat'; +import { injectAgent, provideAgent } from '@threadplane/langgraph'; +import type { BaseMessage } from '@langchain/core/messages'; + +// Declare the ref once (e.g. in agent.ts or app.config.ts): +export interface MyState { + messages: BaseMessage[]; + summary: string; +} +export const MY_AGENT = createAgentRef('my-agent'); + +// Register in app.config.ts providers: +provideAgent(MY_AGENT, { + apiUrl: 'http://localhost:2024', + threadId: () => localStorage.getItem('threadId') ?? undefined, + onThreadId: (id) => localStorage.setItem('threadId', id), +}); + +// Inject in a component or service: +readonly chat = injectAgent(MY_AGENT); +// chat.value() → MyState +// chat.state() → MyState +``` + +See [`createAgentRef`](/docs/langgraph/api/create-agent-ref) for the full ref API, including the optional `BagTemplate` second generic for typed interrupt payloads. + Pair it with `provideAgent()` at bootstrap to configure the API URL, assistant id, thread persistence, and transport: ```ts import { bootstrapApplication } from '@angular/platform-browser'; import { provideAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import { AppComponent } from './app/app.component'; +export const MY_AGENT = createAgentRef('my-agent'); + bootstrapApplication(AppComponent, { providers: [ - provideAgent({ + provideAgent(MY_AGENT, { apiUrl: 'http://localhost:2024', - assistantId: 'my-agent', threadId: () => localStorage.getItem('threadId') ?? undefined, onThreadId: (id) => localStorage.setItem('threadId', id), }), diff --git a/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx b/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx index 5ed8dae1..e199170e 100644 --- a/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx +++ b/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx @@ -173,7 +173,7 @@ interface AgentState { `, }) export class ReactAgentComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(); messages = this.agent.messages; @@ -242,7 +242,7 @@ When the agent calls a tool, `injectAgent()` exposes the execution lifecycle thr // toolCalls() — tool calls with status, args, and results // Updates in real time as tools start and complete -const agent = injectAgent(); +const agent = injectAgent(); // Each entry has: id, name, args, status, and optional result const activeTools = computed(() => @@ -435,7 +435,7 @@ interface OrchestratorState { `, }) export class MultiAgentComponent { - protected readonly orchestrator = injectAgent(); + protected readonly orchestrator = injectAgent(); messages = this.orchestrator.messages; @@ -488,7 +488,7 @@ When `handle_tool_error=True` is set, LangGraph catches `ToolException` and feed ### How Errors Surface in Angular ```typescript -const agent = injectAgent(); +const agent = injectAgent(); // The error() signal captures both transport and agent errors const error = computed(() => agent.error()); @@ -555,7 +555,7 @@ provideAgent({ }); // component.ts -const agent = injectAgent(); +const agent = injectAgent(); // Full checkpoint timeline — every state snapshot const timeline = computed(() => agent.history()); diff --git a/apps/website/content/docs/langgraph/concepts/angular-signals.mdx b/apps/website/content/docs/langgraph/concepts/angular-signals.mdx index cad0a805..e4866686 100644 --- a/apps/website/content/docs/langgraph/concepts/angular-signals.mdx +++ b/apps/website/content/docs/langgraph/concepts/angular-signals.mdx @@ -61,6 +61,7 @@ const status = toSignal(status$, { initialValue: 'idle' }); ```typescript import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import type { BaseMessage } from '@langchain/core/messages'; // The type parameter mirrors your graph's state schema — see State Management. @@ -68,9 +69,11 @@ interface ChatState { messages: BaseMessage[]; } +export const CHAT_AGENT = createAgentRef('chat'); + // You never touch BehaviorSubjects or toSignal() yourself. -// injectAgent() hands you clean Signals (config comes from provideAgent): -protected readonly chat = injectAgent(); +// injectAgent(ref) hands you clean Signals (config comes from provideAgent): +protected readonly chat = injectAgent(CHAT_AGENT); chat.messages(); // Signal chat.status(); // Signal<'idle' | 'running' | 'error'> @@ -101,7 +104,7 @@ Every `injectAgent()` instance moves through a lifecycle: **idle**, **running** The resource has been created but no request has been submitted yet. All Signals hold their initial values. ```typescript -protected readonly chat = injectAgent(); +protected readonly chat = injectAgent(); console.log(chat.status()); // 'idle' console.log(chat.messages()); // [] @@ -164,7 +167,7 @@ console.log(chat.isLoading()); // false import { computed } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; -protected readonly chat = injectAgent(); +protected readonly chat = injectAgent(); // Count all messages in the conversation const messageCount = computed(() => chat.messages().length); @@ -332,7 +335,7 @@ import { injectAgent } from '@threadplane/langgraph'; export class ChatComponent { @ViewChild('chatContainer') chatContainer!: ElementRef; - protected readonly chat = injectAgent(); + protected readonly chat = injectAgent(); errorDisplay = computed(() => { const err = this.chat.error(); @@ -396,7 +399,7 @@ Since `injectAgent()` exposes Signals, condition 3 handles everything. When a ne `, }) export class ChatComponent { - protected readonly chat = injectAgent(); + protected readonly chat = injectAgent(); } ``` @@ -483,7 +486,7 @@ import { injectAgent } from '@threadplane/langgraph'; `, }) export class ChatComponent { - protected readonly chat = injectAgent(); + protected readonly chat = injectAgent(); // Derived state from the Python agent's output toolsUsed = computed(() => diff --git a/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx b/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx index fa5f7e7b..24718c8b 100644 --- a/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx +++ b/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx @@ -163,7 +163,7 @@ graph = builder.compile() **Angular connection:** Track tool execution in real-time: ```typescript -const agent = injectAgent(); +const agent = injectAgent(); // Watch tools execute const activeTools = computed(() => agent.toolCalls()); @@ -200,7 +200,7 @@ def execute_action(state: State) -> dict: **Angular connection:** The interrupt surfaces automatically: ```typescript -const agent = injectAgent(); +const agent = injectAgent(); // Show approval UI when agent pauses const pendingAction = computed(() => agent.interrupt()); @@ -242,7 +242,7 @@ provideAgent({ Then consume from any component: ```typescript -const orchestrator = injectAgent(); +const orchestrator = injectAgent(); const workers = computed(() => [...orchestrator.subagents().values()]); const workerCount = computed(() => workers().length); @@ -276,7 +276,7 @@ provideAgent({ }); // component.ts -const chat = injectAgent(); +const chat = injectAgent(); // User returns tomorrow — same thread, full history restored // No code needed — the adapter handles it diff --git a/apps/website/content/docs/langgraph/concepts/state-management.mdx b/apps/website/content/docs/langgraph/concepts/state-management.mdx index 92b29d5b..e18a3086 100644 --- a/apps/website/content/docs/langgraph/concepts/state-management.mdx +++ b/apps/website/content/docs/langgraph/concepts/state-management.mdx @@ -154,6 +154,8 @@ class ProjectState(MessagesState): ```typescript import { BaseMessage } from '@langchain/core/messages'; +import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; interface ProjectState { // Maps from MessagesState.messages @@ -167,7 +169,12 @@ interface ProjectState { error: string | null; } -const agent = injectAgent(); +export const PROJECT_AGENT = createAgentRef('project_agent'); + +// Configure in app.config.ts: +// provideAgent(PROJECT_AGENT, { apiUrl: '...', assistantId: 'project_agent' }); + +const agent = injectAgent(PROJECT_AGENT); ``` @@ -212,7 +219,7 @@ The agent doesn't wait until it's finished to send state updates. It streams par `injectAgent()` requests the stream modes it needs to populate its public signals by default: `['values', 'messages-tuple', 'updates', 'custom']`. Those feed state snapshots, streamed message tokens, per-node update events, and custom events respectively. Passing your own `streamMode` *replaces* that default set, so narrowing to a single mode means giving up the signals the others populate. Use `values` when you only need state snapshots, and `messages-tuple` when you only need individual message tokens. ```typescript -const agent = injectAgent(); +const agent = injectAgent(PROJECT_AGENT); agent.submit( { message: 'Update the project plan.' }, @@ -246,7 +253,7 @@ Because every state update is a new signal value, your templates reflect the age ` }) export class ProjectComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(PROJECT_AGENT); } ``` @@ -283,12 +290,15 @@ There are two kinds of state in a LangGraph Angular app, and keeping them separa **Application state** is owned by your Angular component or service. It's UI-only: sidebar visibility, active tab, selected message, form input values. It has nothing to do with the agent. ```typescript +// Defined once (e.g. in agent.ts): +// export const CHAT_AGENT = createAgentRef('chat'); + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, }) export class ChatComponent { // --- Thread state (from injectAgent(), read-only) --- - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(CHAT_AGENT); // Convenience computed values from thread state readonly messages = this.agent.messages; // Signal @@ -342,9 +352,9 @@ Thread: "user_123_session" ```typescript // app.config.ts — configure threadId/onThreadId on the provider -provideAgent({ +// (CHAT_AGENT = createAgentRef('chat'), declared in agent.ts) +provideAgent(CHAT_AGENT, { apiUrl: '...', - assistantId: 'chat', // Same threadId = restored conversation history threadId: signal(this.route.snapshot.params['threadId']), @@ -354,7 +364,7 @@ provideAgent({ }); // component.ts -const agent = injectAgent(); +const agent = injectAgent(CHAT_AGENT); // Read checkpoint history for time-travel UI const history = agent.history(); // Signal @@ -424,6 +434,7 @@ def researcher_node(state: ResearchState) -> dict: ```typescript import { BaseMessage } from '@langchain/core/messages'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; interface ResearchState { messages: BaseMessage[]; @@ -440,11 +451,13 @@ interface ResearchState { model_used: string; } +export const RESEARCH_AGENT = createAgentRef('research_agent'); + // Configured globally in app.config.ts: -// provideAgent({ apiUrl: '...', assistantId: 'research_agent' }); +// provideAgent(RESEARCH_AGENT, { apiUrl: '...' }); // In your component: -protected readonly agent = injectAgent(); +protected readonly agent = injectAgent(RESEARCH_AGENT); ``` @@ -490,7 +503,7 @@ protected readonly agent = injectAgent(); ` }) export class ResearchComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(RESEARCH_AGENT); startResearch(query: string) { this.agent.submit({ message: query }); @@ -506,7 +519,7 @@ export class ResearchComponent { You rarely need to consume `agent.value()` raw in your template. Use `computed()` to derive clean, focused values: ```typescript -protected readonly agent = injectAgent(); +protected readonly agent = injectAgent(RESEARCH_AGENT); // Derived signals — recalculate only when their dependencies change readonly progress = computed(() => this.agent.value().progress); diff --git a/apps/website/content/docs/langgraph/guides/deployment.mdx b/apps/website/content/docs/langgraph/guides/deployment.mdx index ca8a4a64..7600f0ba 100644 --- a/apps/website/content/docs/langgraph/guides/deployment.mdx +++ b/apps/website/content/docs/langgraph/guides/deployment.mdx @@ -215,7 +215,9 @@ function httpStatus(err: unknown): number | undefined { `, }) export class ChatComponent { - protected readonly chat = injectAgent(); + // injectAgent() without a ref returns the default-typed agent (state/value are Record). + // This component only uses status() and error(), so the no-arg form is sufficient. + protected readonly chat = injectAgent(); hasError = computed(() => this.chat.status() === 'error'); diff --git a/apps/website/content/docs/langgraph/guides/interrupts.mdx b/apps/website/content/docs/langgraph/guides/interrupts.mdx index 982d3f2e..0280f14e 100644 --- a/apps/website/content/docs/langgraph/guides/interrupts.mdx +++ b/apps/website/content/docs/langgraph/guides/interrupts.mdx @@ -145,6 +145,7 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import type { BaseMessage } from '@langchain/core/messages'; interface ApprovalPayload { @@ -160,13 +161,18 @@ interface AgentState { approval_result: { approved: boolean; reason?: string }; } +export const APPROVAL_AGENT = createAgentRef('approval_agent'); + +// Configure in app.config.ts: +// provideAgent(APPROVAL_AGENT, { apiUrl: '...' }); + @Component({ selector: 'app-approval', templateUrl: './approval.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ApprovalComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(APPROVAL_AGENT); messages = computed(() => this.agent.messages()); pendingApproval = computed(() => this.agent.interrupt()); @@ -362,6 +368,7 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import type { BaseMessage } from '@langchain/core/messages'; interface StepApproval { @@ -380,13 +387,18 @@ type DeployState = { approval_result: { approved: boolean; reason?: string }; }; +export const DEPLOY_AGENT = createAgentRef('approval_agent'); + +// Configure in app.config.ts: +// provideAgent(DEPLOY_AGENT, { apiUrl: '...' }); + @Component({ selector: 'app-deploy-approval', templateUrl: './approval.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeployApprovalComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(DEPLOY_AGENT); currentStep = computed(() => { const interrupt = this.agent.interrupt(); @@ -464,6 +476,7 @@ By default, `interrupt()` returns an untyped value. The `BagTemplate` generic pa ```typescript import { injectAgent, BagTemplate } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; // Define the exact shape of your interrupt payload interface DeployApproval { @@ -478,7 +491,10 @@ type DeployBag = BagTemplate & { InterruptType: DeployApproval; }; -const agent = injectAgent(); +// createAgentRef accepts the BagTemplate as its second generic +export const TYPED_DEPLOY_AGENT = createAgentRef('approval_agent'); + +const agent = injectAgent(TYPED_DEPLOY_AGENT); const raw = agent.langGraphInterrupts(); // ^? Interrupt[] diff --git a/apps/website/content/docs/langgraph/guides/memory.mdx b/apps/website/content/docs/langgraph/guides/memory.mdx index 0322626a..5db86cf8 100644 --- a/apps/website/content/docs/langgraph/guides/memory.mdx +++ b/apps/website/content/docs/langgraph/guides/memory.mdx @@ -81,6 +81,7 @@ graph = builder.compile() ```typescript import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; import type { BaseMessage } from '@langchain/core/messages'; interface AgentState { @@ -90,10 +91,11 @@ interface AgentState { mentioned_topics: string[]; } +export const MEMORY_AGENT = createAgentRef('memory_agent'); + // Configure in app.config.ts: -// provideAgent({ +// provideAgent(MEMORY_AGENT, { // apiUrl: '...', -// assistantId: 'memory_agent', // threadId: signal(localStorage.getItem('memory-thread')), // onThreadId: (id) => localStorage.setItem('memory-thread', id), // }); @@ -104,7 +106,7 @@ interface AgentState { changeDetection: ChangeDetectionStrategy.OnPush, }) export class MemoryChatComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(MEMORY_AGENT); // Reactive memory signals derived from agent state preferences = computed(() => this.agent.value()?.user_preferences ?? {}); @@ -196,15 +198,17 @@ result = graph.invoke( On the Angular side, thread-scoped memory requires no extra code. The `threadId` signal handles it — configure it once in `provideAgent({...})`: ```typescript +// agent.ts (shared) +// export const MEMORY_AGENT = createAgentRef('memory_agent'); + // app.config.ts -provideAgent({ +provideAgent(MEMORY_AGENT, { apiUrl: '...', - assistantId: 'memory_agent', threadId: signal(userId()), // Same user = same thread = same memory }); // component.ts -const chat = injectAgent(); +const chat = injectAgent(MEMORY_AGENT); // chat.messages() restores full history on reconnect // chat.value() restores all custom state fields diff --git a/apps/website/content/docs/langgraph/guides/subgraphs.mdx b/apps/website/content/docs/langgraph/guides/subgraphs.mdx index d3b67060..05aaeaae 100644 --- a/apps/website/content/docs/langgraph/guides/subgraphs.mdx +++ b/apps/website/content/docs/langgraph/guides/subgraphs.mdx @@ -76,6 +76,14 @@ graph = builder.compile() ```typescript import { Component, computed, inject, effect, ChangeDetectionStrategy } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { createAgentRef } from '@threadplane/chat'; + +export interface OrchestratorState { + messages: BaseMessage[]; + // add your subgraph output fields here +} + +export const ORCHESTRATOR = createAgentRef('orchestrator'); @Component({ selector: 'app-orchestrator', @@ -83,7 +91,7 @@ import { injectAgent } from '@threadplane/langgraph'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class OrchestratorComponent { - protected readonly orchestrator = injectAgent(); + protected readonly orchestrator = injectAgent(ORCHESTRATOR); readonly messages = computed(() => this.orchestrator.messages()); readonly isRunning = computed(() => this.orchestrator.isLoading()); @@ -98,7 +106,7 @@ export class OrchestratorComponent { -`OrchestratorState` and `PipelineState` below are placeholders for your own graph's state schema — the shape your subgraph's `StateGraph` produces. They mirror the Python state the same way `ChatState` does on the [State Management](/docs/langgraph/concepts/state-management) page. A `messages`-only graph is just `injectAgent<{ messages: BaseMessage[] }>()`. +`OrchestratorState` and `PipelineState` below are placeholders for your own graph's state schema — the shape your subgraph's `StateGraph` produces. They mirror the Python state the same way `ChatState` does on the [State Management](/docs/langgraph/concepts/state-management) page. Use `createAgentRef('your-assistant-id')` to create a typed ref, then pass it to both `provideAgent()` and `injectAgent()`. ## Tracking delegated subagent execution @@ -106,14 +114,17 @@ export class OrchestratorComponent { The `subagents()` signal contains a Map of active delegated subagent streams. Use it when your graph delegates through tool calls, such as Deep Agents' default `task` tool or your own delegation tools. Plain subgraph nodes do not appear in this map. ```typescript +// In a shared file (e.g. agent.ts): +// import { createAgentRef } from '@threadplane/chat'; +// export const ORCHESTRATOR = createAgentRef('orchestrator'); + // Configure in app.config.ts: -// provideAgent({ +// provideAgent(ORCHESTRATOR, { // apiUrl: '...', -// assistantId: 'orchestrator', // subagentToolNames: ['task', 'delegate_to_researcher'], // }); -const orchestrator = injectAgent(); +const orchestrator = injectAgent(ORCHESTRATOR); // All subagent streams (active and completed) const subagents = computed(() => orchestrator.subagents()); @@ -164,15 +175,18 @@ const researchMessages = computed(() => researchAgent()?.messages() ?? []); The orchestrator pattern delegates specialised work to subagents and merges their results. Each subagent runs its own graph independently while the parent coordinates the whole. ```typescript +// In a shared file (e.g. agent.ts): +// import { createAgentRef } from '@threadplane/chat'; +// export const PIPELINE = createAgentRef('pipeline-orchestrator'); + // Configure in app.config.ts: -// provideAgent({ +// provideAgent(PIPELINE, { // apiUrl: '...', -// assistantId: 'pipeline-orchestrator', // subagentToolNames: ['task'], // filterSubagentMessages: true, // }); -const pipeline = injectAgent(); +const pipeline = injectAgent(PIPELINE); // Derive a summary of all subagent states const pipelineStatus = computed(() => { @@ -198,6 +212,7 @@ Render live progress for each subagent using the signals above. ```typescript import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; import { injectAgent } from '@threadplane/langgraph'; +import { ORCHESTRATOR } from './agent'; // createAgentRef('orchestrator') @Component({ selector: 'app-subagent-progress', @@ -205,7 +220,7 @@ import { injectAgent } from '@threadplane/langgraph'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SubagentProgressComponent { - protected readonly orchestrator = injectAgent(); + protected readonly orchestrator = injectAgent(ORCHESTRATOR); subagentEntries = computed(() => [...this.orchestrator.subagents().entries()] @@ -240,15 +255,18 @@ export class SubagentProgressComponent { By default, subagent messages appear in the parent's `messages()` signal. Filter them out for a cleaner parent view. ```typescript +// In a shared file (e.g. agent.ts): +// import { createAgentRef } from '@threadplane/chat'; +// export const ORCHESTRATOR = createAgentRef('orchestrator'); + // Configure in app.config.ts: -// provideAgent({ +// provideAgent(ORCHESTRATOR, { // apiUrl: '...', -// assistantId: 'orchestrator', // filterSubagentMessages: true, // Hide subagent messages from parent // subagentToolNames: ['task'], // }); -const orchestrator = injectAgent(); +const orchestrator = injectAgent(ORCHESTRATOR); // Parent messages only (no subagent chatter) const parentMessages = computed(() => orchestrator.messages()); diff --git a/apps/website/content/docs/langgraph/guides/time-travel.mdx b/apps/website/content/docs/langgraph/guides/time-travel.mdx index 4ce3c588..c05ff091 100644 --- a/apps/website/content/docs/langgraph/guides/time-travel.mdx +++ b/apps/website/content/docs/langgraph/guides/time-travel.mdx @@ -102,8 +102,12 @@ export class HistoryViewerComponent { The `history()` signal contains runtime-neutral `AgentCheckpoint` entries for the thread. For LangGraph-specific checkpoint metadata, `langGraphHistory()` exposes the raw `ThreadState[]`. The framework loads this history with `threads.getHistory()` when a thread is selected and refreshes it after a run completes. ```typescript -// Configured globally in app.config.ts via provideAgent({ ... threadId, assistantId }). -const agent = injectAgent(); +// agent.ts (shared): +// import { createAgentRef } from '@threadplane/chat'; +// export const MY_AGENT = createAgentRef('my-agent'); + +// Configured globally in app.config.ts via provideAgent(MY_AGENT, { ... threadId }). +const agent = injectAgent(MY_AGENT); // Runtime-neutral execution timeline const checkpoints = computed(() => agent.history()); @@ -249,13 +253,17 @@ Use the comparison result to render a diff view, highlight changed fields in you Combine forking with new input to explore how the agent would have responded differently. This is the core of the undo/redo experience. ```typescript +// agent.ts (shared): +// import { createAgentRef } from '@threadplane/chat'; +// export const MY_AGENT = createAgentRef('my-agent'); + @Component({ selector: 'app-replay', templateUrl: './replay.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReplayComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(MY_AGENT); readonly history = computed(() => this.agent.history()); readonly rawHistory = computed(() => this.agent.langGraphHistory()); From 129c3578f9dc1e90dd56ab1188235cdaa87e13f6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 11:03:43 -0700 Subject: [PATCH 16/24] =?UTF-8?q?docs(langgraph):=20fix=20BagTemplate=20ex?= =?UTF-8?q?ample=20=E2=80=94=20createAgentRef=20takes=20one=20generic;=20b?= =?UTF-8?q?ag=20at=20inject=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/website/content/docs/langgraph/guides/interrupts.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/website/content/docs/langgraph/guides/interrupts.mdx b/apps/website/content/docs/langgraph/guides/interrupts.mdx index 0280f14e..485461cf 100644 --- a/apps/website/content/docs/langgraph/guides/interrupts.mdx +++ b/apps/website/content/docs/langgraph/guides/interrupts.mdx @@ -491,10 +491,10 @@ type DeployBag = BagTemplate & { InterruptType: DeployApproval; }; -// createAgentRef accepts the BagTemplate as its second generic -export const TYPED_DEPLOY_AGENT = createAgentRef('approval_agent'); +// The ref carries the state shape; pass the BagTemplate at the inject site. +export const TYPED_DEPLOY_AGENT = createAgentRef('approval_agent'); -const agent = injectAgent(TYPED_DEPLOY_AGENT); +const agent = injectAgent(TYPED_DEPLOY_AGENT); const raw = agent.langGraphInterrupts(); // ^? Interrupt[] From 2a44a0c9855c5435107ef849e9ae72373362996e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 11:09:04 -0700 Subject: [PATCH 17/24] test(cockpit/ag-ui): client-tools app on strict + typed tools/view/ask (forcing function) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip strict: false → strict: true in the example app's tsconfig (the flag that previously masked function-type variance bugs). The app was already using the new generic action/view/ask/tools API, so no call-site changes were needed; the build passed clean immediately. Migrate the view/ask components to anchor their input types to the shared schemas via ViewProps, so a schema change is a compile error on the component rather than a silent runtime mismatch. Extract schemas to schemas.ts so both the registry call site and the components reference the same schema object. Co-Authored-By: Claude Sonnet 4.6 --- .../angular/src/app/client-tools.component.ts | 15 ++++++------ .../src/app/confirm-booking.component.ts | 12 +++++++++- .../client-tools/angular/src/app/schemas.ts | 21 +++++++++++++++++ .../angular/src/app/weather-card.component.ts | 23 +++++++++++++++---- .../ag-ui/client-tools/angular/tsconfig.json | 3 +-- 5 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 cockpit/ag-ui/client-tools/angular/src/app/schemas.ts diff --git a/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts index 9c85cb01..58724d47 100644 --- a/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts +++ b/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts @@ -6,6 +6,7 @@ import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { z } from 'zod/v4'; import { WeatherCardComponent } from './weather-card.component'; import { ConfirmBookingComponent } from './confirm-booking.component'; +import { weatherCardSchema, confirmBookingSchema } from './schemas'; /** * Client-tools demo — tools declared in the browser that the model calls and @@ -15,6 +16,10 @@ import { ConfirmBookingComponent } from './confirm-booking.component'; * component whose emitted value becomes the result). The catalog is shipped to * the model via the AG-UI adapter; the backend graph binds the client stubs * (no server implementation) and ends the turn so the browser executes them. + * + * Under `strict: true` the typed `view`/`ask` overloads verify at compile time + * that every field the schema produces is a declared `input()` on the paired + * component. Mismatches become errors here, not silent runtime failures. */ const clientTools = tools({ get_weather: action( @@ -24,18 +29,12 @@ const clientTools = tools({ ), weather_card: view( 'Display a weather card for a location with the given readings.', - z.object({ - location: z.string(), - temperatureF: z.number(), - conditions: z.string(), - humidity: z.number(), - windMph: z.number(), - }), + weatherCardSchema, WeatherCardComponent, ), confirm_booking: ask( 'Ask the user to confirm a booking before finalizing it.', - z.object({ summary: z.string() }), + confirmBookingSchema, ConfirmBookingComponent, ), }); diff --git a/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts index 94ab9e96..30c44822 100644 --- a/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts +++ b/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; import { injectRenderHost } from '@threadplane/render'; +import { confirmBookingSchema } from './schemas'; /** * The interactive component for the `confirm_booking` client tool (an `ask`). @@ -13,7 +15,14 @@ import { injectRenderHost } from '@threadplane/render'; * prop (chat-tool-views spreads `{...args, ...result, status}` into it). When * `confirmed()` is defined we render a FROZEN line with no buttons; the live * interactive card only shows while `confirmed()` is still undefined. + * + * Input types for schema-derived props are anchored to `ViewProps` — a schema change is a compile error here. */ + +/** Props this component receives from the `confirm_booking` schema. */ +type ConfirmBookingProps = ViewProps; + @Component({ selector: 'app-confirm-booking', standalone: true, @@ -47,7 +56,8 @@ import { injectRenderHost } from '@threadplane/render'; `], }) export class ConfirmBookingComponent { - readonly summary = input(); + // Schema-derived input — type anchored to ConfirmBookingProps. + readonly summary = input(); /** Spread back onto props after the ask resolves (undefined while interactive). */ readonly confirmed = input(undefined); private readonly host = injectRenderHost(); diff --git a/cockpit/ag-ui/client-tools/angular/src/app/schemas.ts b/cockpit/ag-ui/client-tools/angular/src/app/schemas.ts new file mode 100644 index 00000000..00a7cd95 --- /dev/null +++ b/cockpit/ag-ui/client-tools/angular/src/app/schemas.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +/** + * Shared Zod schemas for the client-tools demo. + * + * Exporting them here lets each view/ask component anchor its `ViewProps` type annotation directly to the schema — so a schema change is a + * compile error on the component, not a silent runtime mismatch. + */ +import { z } from 'zod/v4'; + +/** Schema for the `weather_card` view tool. */ +export const weatherCardSchema = z.object({ + location: z.string(), + temperatureF: z.number(), + conditions: z.string(), + humidity: z.number(), + windMph: z.number(), +}); + +/** Schema for the `confirm_booking` ask tool. */ +export const confirmBookingSchema = z.object({ summary: z.string() }); diff --git a/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts index c5976655..478f0918 100644 --- a/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts +++ b/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; +import { weatherCardSchema } from './schemas'; /** * A frontend-owned view rendered for the `weather_card` tool call. Receives @@ -7,7 +9,14 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co * on completion (`temperatureF`, `conditions`, `humidity`, `windMph`), and a * `status` of 'running' | 'complete'. Renders a loading affordance until the * result arrives. + * + * Input types are derived from {@link weatherCardSchema} via `ViewProps` so + * that a schema change is a compile error here under `strict: true`. */ + +/** Props this component receives from the `weather_card` schema. */ +type WeatherCardProps = ViewProps; + @Component({ selector: 'app-weather-card', standalone: true, @@ -41,11 +50,15 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co `], }) export class WeatherCardComponent { - readonly location = input(); - readonly temperatureF = input(); - readonly conditions = input(); - readonly humidity = input(); - readonly windMph = input(); + // Schema-derived inputs — types anchored to WeatherCardProps so a schema + // change is a compile error. Optional because the framework sends partial + // props during streaming (args arrive before the tool result). + readonly location = input(); + readonly temperatureF = input(); + readonly conditions = input(); + readonly humidity = input(); + readonly windMph = input(); + /** Extra input not in the schema: injected by the framework for rendering state. */ readonly status = input<'running' | 'complete'>(); readonly pending = computed(() => this.status() !== 'complete' || this.temperatureF() === undefined); diff --git a/cockpit/ag-ui/client-tools/angular/tsconfig.json b/cockpit/ag-ui/client-tools/angular/tsconfig.json index 35cdb00e..00d3e70a 100644 --- a/cockpit/ag-ui/client-tools/angular/tsconfig.json +++ b/cockpit/ag-ui/client-tools/angular/tsconfig.json @@ -8,8 +8,7 @@ "composite": false, "lib": ["es2022", "dom"], "skipLibCheck": true, - "strict": false, - "strictNullChecks": true + "strict": true }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, From a496b953223361f78a5c5af85a76cae3bd375b7f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 11:13:45 -0700 Subject: [PATCH 18/24] test(cockpit/langgraph): client-tools app on strict + typed tools/view/ask + createAgentRef typed state (forcing function) Co-Authored-By: Claude Sonnet 4.6 --- .../client-tools/angular/src/app/agent-ref.ts | 23 ++++++++++++++ .../angular/src/app/app.config.ts | 3 +- .../angular/src/app/client-tools.component.ts | 31 +++++++++++++------ .../src/app/confirm-booking.component.ts | 12 ++++++- .../client-tools/angular/src/app/schemas.ts | 21 +++++++++++++ .../angular/src/app/weather-card.component.ts | 23 +++++++++++--- .../client-tools/angular/tsconfig.json | 3 +- 7 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 cockpit/langgraph/client-tools/angular/src/app/agent-ref.ts create mode 100644 cockpit/langgraph/client-tools/angular/src/app/schemas.ts diff --git a/cockpit/langgraph/client-tools/angular/src/app/agent-ref.ts b/cockpit/langgraph/client-tools/angular/src/app/agent-ref.ts new file mode 100644 index 00000000..b3b60bcf --- /dev/null +++ b/cockpit/langgraph/client-tools/angular/src/app/agent-ref.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; +import type { BaseMessage } from '@langchain/core/messages'; + +/** + * State shape of the LangGraph client-tools graph (mirrors the Python TypedDict + * in cockpit/langgraph/client-tools/python/src/graph.py). + * + * `messages` is the LangChain message channel (add_messages-annotated list). + * `client_tools` carries the browser-declared tool catalog that the + * `@threadplane/langgraph` adapter ships as `input.client_tools`. + */ +export interface ClientToolsState { + messages: BaseMessage[]; + client_tools: unknown[]; +} + +/** + * Typed DI ref for the client-tools LangGraph agent. + * Pass to `provideAgent` in `app.config.ts` and `injectAgent` in components + * to carry `ClientToolsState` through DI without repeating the generic. + */ +export const CLIENT_TOOLS_AGENT_REF = createAgentRef('client-tools'); diff --git a/cockpit/langgraph/client-tools/angular/src/app/app.config.ts b/cockpit/langgraph/client-tools/angular/src/app/app.config.ts index b08e6d2b..282bb737 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/app.config.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/app.config.ts @@ -3,10 +3,11 @@ import { ApplicationConfig } from '@angular/core'; import { provideAgent } from '@threadplane/langgraph'; import { provideChat } from '@threadplane/chat'; import { environment } from '../environments/environment'; +import { CLIENT_TOOLS_AGENT_REF } from './agent-ref'; export const appConfig: ApplicationConfig = { providers: [ - provideAgent({ + provideAgent(CLIENT_TOOLS_AGENT_REF, { apiUrl: environment.langGraphApiUrl, assistantId: environment.clientToolsAssistantId, }), diff --git a/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts b/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts index 942d0d59..112d96e6 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MIT -import { Component } from '@angular/core'; +import { Component, computed } from '@angular/core'; import { ChatComponent, tools, action, view, ask } from '@threadplane/chat'; import { injectAgent } from '@threadplane/langgraph'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { z } from 'zod/v4'; import { WeatherCardComponent } from './weather-card.component'; import { ConfirmBookingComponent } from './confirm-booking.component'; +import { weatherCardSchema, confirmBookingSchema } from './schemas'; +import { CLIENT_TOOLS_AGENT_REF, type ClientToolsState } from './agent-ref'; /** * Client-tools demo — tools declared in the browser that the model calls and @@ -16,6 +18,10 @@ import { ConfirmBookingComponent } from './confirm-booking.component'; * the model via the LangGraph adapter (as `input.client_tools`); the backend * graph binds the client stubs (no server implementation) and ends the turn so * the browser executes them. + * + * Under `strict: true` the typed `view`/`ask` overloads verify at compile time + * that every field the schema produces is a declared `input()` on the paired + * component. Mismatches become errors here, not silent runtime failures. */ const clientTools = tools({ get_weather: action( @@ -25,18 +31,12 @@ const clientTools = tools({ ), weather_card: view( 'Display a weather card for a location with the given readings.', - z.object({ - location: z.string(), - temperatureF: z.number(), - conditions: z.string(), - humidity: z.number(), - windMph: z.number(), - }), + weatherCardSchema, WeatherCardComponent, ), confirm_booking: ask( 'Ask the user to confirm a booking before finalizing it.', - z.object({ summary: z.string() }), + confirmBookingSchema, ConfirmBookingComponent, ), }); @@ -52,6 +52,17 @@ const clientTools = tools({ `, }) export class ClientToolsComponent { - protected readonly agent = injectAgent(); + /** Typed agent: state() and value() are ClientToolsState. */ + protected readonly agent = injectAgent(CLIENT_TOOLS_AGENT_REF); protected readonly clientTools = clientTools; + + /** + * Typed state read — proves the typed DI path compiles under strict: true. + * `messages` and `client_tools` are read from the strongly-typed + * `ClientToolsState` shape; the compiler errors if the field does not exist. + */ + protected readonly messageCount = computed((): number => { + const s: ClientToolsState = this.agent.value(); + return s.messages.length; + }); } diff --git a/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts b/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts index 94ab9e96..30c44822 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; import { injectRenderHost } from '@threadplane/render'; +import { confirmBookingSchema } from './schemas'; /** * The interactive component for the `confirm_booking` client tool (an `ask`). @@ -13,7 +15,14 @@ import { injectRenderHost } from '@threadplane/render'; * prop (chat-tool-views spreads `{...args, ...result, status}` into it). When * `confirmed()` is defined we render a FROZEN line with no buttons; the live * interactive card only shows while `confirmed()` is still undefined. + * + * Input types for schema-derived props are anchored to `ViewProps` — a schema change is a compile error here. */ + +/** Props this component receives from the `confirm_booking` schema. */ +type ConfirmBookingProps = ViewProps; + @Component({ selector: 'app-confirm-booking', standalone: true, @@ -47,7 +56,8 @@ import { injectRenderHost } from '@threadplane/render'; `], }) export class ConfirmBookingComponent { - readonly summary = input(); + // Schema-derived input — type anchored to ConfirmBookingProps. + readonly summary = input(); /** Spread back onto props after the ask resolves (undefined while interactive). */ readonly confirmed = input(undefined); private readonly host = injectRenderHost(); diff --git a/cockpit/langgraph/client-tools/angular/src/app/schemas.ts b/cockpit/langgraph/client-tools/angular/src/app/schemas.ts new file mode 100644 index 00000000..00a7cd95 --- /dev/null +++ b/cockpit/langgraph/client-tools/angular/src/app/schemas.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +/** + * Shared Zod schemas for the client-tools demo. + * + * Exporting them here lets each view/ask component anchor its `ViewProps` type annotation directly to the schema — so a schema change is a + * compile error on the component, not a silent runtime mismatch. + */ +import { z } from 'zod/v4'; + +/** Schema for the `weather_card` view tool. */ +export const weatherCardSchema = z.object({ + location: z.string(), + temperatureF: z.number(), + conditions: z.string(), + humidity: z.number(), + windMph: z.number(), +}); + +/** Schema for the `confirm_booking` ask tool. */ +export const confirmBookingSchema = z.object({ summary: z.string() }); diff --git a/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts b/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts index c5976655..478f0918 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; +import { weatherCardSchema } from './schemas'; /** * A frontend-owned view rendered for the `weather_card` tool call. Receives @@ -7,7 +9,14 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co * on completion (`temperatureF`, `conditions`, `humidity`, `windMph`), and a * `status` of 'running' | 'complete'. Renders a loading affordance until the * result arrives. + * + * Input types are derived from {@link weatherCardSchema} via `ViewProps` so + * that a schema change is a compile error here under `strict: true`. */ + +/** Props this component receives from the `weather_card` schema. */ +type WeatherCardProps = ViewProps; + @Component({ selector: 'app-weather-card', standalone: true, @@ -41,11 +50,15 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co `], }) export class WeatherCardComponent { - readonly location = input(); - readonly temperatureF = input(); - readonly conditions = input(); - readonly humidity = input(); - readonly windMph = input(); + // Schema-derived inputs — types anchored to WeatherCardProps so a schema + // change is a compile error. Optional because the framework sends partial + // props during streaming (args arrive before the tool result). + readonly location = input(); + readonly temperatureF = input(); + readonly conditions = input(); + readonly humidity = input(); + readonly windMph = input(); + /** Extra input not in the schema: injected by the framework for rendering state. */ readonly status = input<'running' | 'complete'>(); readonly pending = computed(() => this.status() !== 'complete' || this.temperatureF() === undefined); diff --git a/cockpit/langgraph/client-tools/angular/tsconfig.json b/cockpit/langgraph/client-tools/angular/tsconfig.json index 35cdb00e..00d3e70a 100644 --- a/cockpit/langgraph/client-tools/angular/tsconfig.json +++ b/cockpit/langgraph/client-tools/angular/tsconfig.json @@ -8,8 +8,7 @@ "composite": false, "lib": ["es2022", "dom"], "skipLibCheck": true, - "strict": false, - "strictNullChecks": true + "strict": true }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, From 35db42fe87d4a10ddc6b72219aa890b2b4d39faa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 11:20:52 -0700 Subject: [PATCH 19/24] test(examples/ag-ui): canonical itinerary on strict + typed client-tools + createAgentRef (forcing function) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flip tsconfig.json to strict:true (build green immediately — no fallout) - Add ItineraryState interface + ITINERARY_AGENT createAgentRef in client-tools.ts - Wire provideAgent(ITINERARY_AGENT, …) in app.config.ts; injectAgent(ITINERARY_AGENT) in AgUiShell — removes state cast in submit wrapper - Co-locate DAY_CARD_SCHEMA with DayCardComponent; anchor inputs via ViewProps — the headline forcing-function proof that compile-time view/ask linkage guards the NG0950 class of bug - Export CLEAR_DAY_SCHEMA from client-tools for symmetry Co-Authored-By: Claude Sonnet 4.6 --- examples/ag-ui/angular/src/app/app.config.ts | 5 ++- .../ag-ui/angular/src/app/client-tools.ts | 32 ++++++++++++++++--- .../angular/src/app/day-card.component.ts | 20 ++++++++++-- .../src/app/shell/ag-ui-shell.component.ts | 9 ++++-- examples/ag-ui/angular/tsconfig.json | 2 +- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/examples/ag-ui/angular/src/app/app.config.ts b/examples/ag-ui/angular/src/app/app.config.ts index bdc22975..1942f5df 100644 --- a/examples/ag-ui/angular/src/app/app.config.ts +++ b/examples/ag-ui/angular/src/app/app.config.ts @@ -11,6 +11,7 @@ import { provideAgent } from '@threadplane/ag-ui'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; import { ItineraryStore } from './itinerary-store'; +import { ITINERARY_AGENT } from './client-tools'; export const appConfig: ApplicationConfig = { providers: [ @@ -18,7 +19,9 @@ export const appConfig: ApplicationConfig = { provideZonelessChangeDetection(), provideRouter(routes), provideThreadplaneTelemetry(environment.telemetry), - provideAgent({ url: environment.agentUrl }), + // Typed agent provider: flows ItineraryState through DI so every + // injectAgent(ITINERARY_AGENT) call returns AgUiAgent. + provideAgent(ITINERARY_AGENT, { url: environment.agentUrl }), provideChat({ license: environment.license }), // The frontend-owned itinerary is a single shared instance: the panel, // the App component, and the client-tool ask component all inject it, so diff --git a/examples/ag-ui/angular/src/app/client-tools.ts b/examples/ag-ui/angular/src/app/client-tools.ts index 1d553d32..daddf8e5 100644 --- a/examples/ag-ui/angular/src/app/client-tools.ts +++ b/examples/ag-ui/angular/src/app/client-tools.ts @@ -1,11 +1,35 @@ // SPDX-License-Identifier: MIT import { inject } from '@angular/core'; -import { tools, action, view, ask, type ClientToolRegistry } from '@threadplane/chat'; +import { tools, action, view, ask, type ClientToolRegistry, createAgentRef } from '@threadplane/chat'; import { z } from 'zod/v4'; import { ItineraryStore } from './itinerary-store'; -import { DayCardComponent } from './day-card.component'; +import { DayCardComponent, DAY_CARD_SCHEMA } from './day-card.component'; import { ClearDayConfirmComponent } from './clear-day-confirm.component'; +/** + * Shape of the state the itinerary agent reads from `RunAgentInput.state`. + * The Python graph's `State` TypedDict defines these three optional keys; + * the Angular shell writes them into every `submit()` call so the backend + * can pick up the user's palette choices. + */ +export interface ItineraryState { + model?: string; + reasoning_effort?: string; + gen_ui_mode?: string; +} + +/** + * Typed DI handle for the itinerary AG-UI agent. + * Pass to `provideAgent(ITINERARY_AGENT, config)` in app.config and + * `injectAgent(ITINERARY_AGENT)` at the inject site for a typed + * `AgUiAgent` without repeating the generic everywhere. + */ +export const ITINERARY_AGENT = createAgentRef('ItineraryAgent'); + +/** Schema for the `clear_day` ask tool — exported in case consumers want to + * derive types from it (e.g. `ViewProps`). */ +export const CLEAR_DAY_SCHEMA = z.object({ day: z.number().int().min(1) }); + /** Client tools over the frontend-owned itinerary. Call inside an injection * context (e.g. a field initializer in App). The descriptions are the ONLY * steering the model gets — no system-prompt coaching (by design). */ @@ -34,12 +58,12 @@ export function itineraryClientTools(): ClientToolRegistry { ), clear_day: ask( 'Ask the user to confirm clearing every stop on a day, then clear it if they accept.', - z.object({ day: z.number().int().min(1) }), + CLEAR_DAY_SCHEMA, ClearDayConfirmComponent, ), day_card: view( "Show the user a visual recap card for one itinerary day. Call it after add_stop or move_stop with the day's full updated place list.", - z.object({ day: z.number().int().min(1), places: z.array(z.string()) }), + DAY_CARD_SCHEMA, DayCardComponent, ), }); diff --git a/examples/ag-ui/angular/src/app/day-card.component.ts b/examples/ag-ui/angular/src/app/day-card.component.ts index d38d1ef3..43ec6343 100644 --- a/examples/ag-ui/angular/src/app/day-card.component.ts +++ b/examples/ag-ui/angular/src/app/day-card.component.ts @@ -1,5 +1,21 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; +import { z } from 'zod/v4'; + +/** + * Schema for the `day_card` view tool — co-located with the component so the + * inputs and the schema shape can be kept in sync at a glance. + * `client-tools.ts` imports this schema to pass to `view(…, DAY_CARD_SCHEMA, …)`. + */ +export const DAY_CARD_SCHEMA = z.object({ + day: z.number().int().min(1), + places: z.array(z.string()), +}); + +/** Input types derived directly from the `day_card` schema — guarantees this + * component stays compatible with the view() check at compile time. */ +type Inputs = ViewProps; /** * A frontend-owned view rendered for the `day_card` client tool. The model @@ -51,6 +67,6 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'; ], }) export class DayCardComponent { - readonly day = input.required(); - readonly places = input([]); + readonly day = input.required(); + readonly places = input([]); } diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts index b786604a..a308fa70 100644 --- a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts @@ -16,7 +16,7 @@ import { } from '@threadplane/chat'; import { PalettePersistence } from './palette-persistence.service'; import { ItineraryPanelComponent } from '../itinerary-panel.component'; -import { itineraryClientTools } from '../client-tools'; +import { itineraryClientTools, ITINERARY_AGENT } from '../client-tools'; export type DemoMode = 'embed' | 'popup' | 'sidebar'; const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const; @@ -104,8 +104,11 @@ export class AgUiShell { readonly clientTools = itineraryClientTools(); // ── Shared agent: submit wrapper merges the knobs into input.state ────── + // injectAgent(ITINERARY_AGENT) returns AgUiAgent, so + // a.submit's input type carries { state?: ItineraryState } — no cast needed + // to spread the palette knobs into state. readonly agent = (() => { - const a = injectAgent(); + const a = injectAgent(ITINERARY_AGENT); const orig = a.submit.bind(a); (a as { submit: typeof a.submit }).submit = (async ( input: Parameters[0], @@ -115,7 +118,7 @@ export class AgUiShell { { ...(input ?? {}), state: { - ...((input as { state?: Record })?.state ?? {}), + ...(input?.state ?? {}), model: this.model(), reasoning_effort: this.effort(), gen_ui_mode: this.genUiMode(), diff --git a/examples/ag-ui/angular/tsconfig.json b/examples/ag-ui/angular/tsconfig.json index 1139eb96..185f669e 100644 --- a/examples/ag-ui/angular/tsconfig.json +++ b/examples/ag-ui/angular/tsconfig.json @@ -8,7 +8,7 @@ "composite": false, "lib": ["es2022", "dom"], "skipLibCheck": true, - "strict": false + "strict": true }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, From 73f5c18172618c58cfdd8ac00fd57ebb4136ffde Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 11:30:41 -0700 Subject: [PATCH 20/24] test(examples,cockpit): representative createAgentRef typed-state migrations Migrate three apps to the typed-agent `createAgentRef` DI pattern as Tier-2 forcing functions proving the typed path works across app types: - cockpit/langgraph/streaming/angular: STREAMING_AGENT ref, StreamingState - cockpit/chat/messages/angular: MESSAGES_AGENT ref, MessagesState - examples/chat/angular: DEMO_AGENT_REF ref, DemoState (model/reasoning_effort/gen_ui_mode) Each app gets a new agent-ref.ts, provideAgent(ref, config), injectAgent(ref), and a typed read exercising state assignability. All three nx builds pass. Co-Authored-By: Claude Sonnet 4.6 --- .../messages/angular/src/app/agent-ref.ts | 14 +++++++++++++ .../messages/angular/src/app/app.config.ts | 3 ++- .../angular/src/app/messages.component.ts | 5 ++++- .../streaming/angular/src/app/agent-ref.ts | 14 +++++++++++++ .../streaming/angular/src/app/app.config.ts | 3 ++- .../angular/src/app/streaming.component.ts | 5 ++++- .../chat/angular/src/app/shell/agent-ref.ts | 21 +++++++++++++++++++ .../src/app/shell/demo-shell.component.ts | 8 +++++-- 8 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 cockpit/chat/messages/angular/src/app/agent-ref.ts create mode 100644 cockpit/langgraph/streaming/angular/src/app/agent-ref.ts create mode 100644 examples/chat/angular/src/app/shell/agent-ref.ts diff --git a/cockpit/chat/messages/angular/src/app/agent-ref.ts b/cockpit/chat/messages/angular/src/app/agent-ref.ts new file mode 100644 index 00000000..47cf145c --- /dev/null +++ b/cockpit/chat/messages/angular/src/app/agent-ref.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; + +/** State shape for the messages cockpit (LangGraph MessagesState). */ +export interface MessagesState { + messages: unknown[]; +} + +/** + * Typed DI handle for the messages agent. + * Wire with `provideAgent(MESSAGES_AGENT, { ... })` and inject with + * `injectAgent(MESSAGES_AGENT)` to get `LangGraphAgent`. + */ +export const MESSAGES_AGENT = createAgentRef('messages'); diff --git a/cockpit/chat/messages/angular/src/app/app.config.ts b/cockpit/chat/messages/angular/src/app/app.config.ts index 67b6e650..12f61db9 100644 --- a/cockpit/chat/messages/angular/src/app/app.config.ts +++ b/cockpit/chat/messages/angular/src/app/app.config.ts @@ -3,10 +3,11 @@ import { ApplicationConfig } from '@angular/core'; import { provideAgent } from '@threadplane/langgraph'; import { provideChat } from '@threadplane/chat'; import { environment } from '../environments/environment'; +import { MESSAGES_AGENT } from './agent-ref'; export const appConfig: ApplicationConfig = { providers: [ - provideAgent({ + provideAgent(MESSAGES_AGENT, { apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }), diff --git a/cockpit/chat/messages/angular/src/app/messages.component.ts b/cockpit/chat/messages/angular/src/app/messages.component.ts index 1eeec73c..7044e717 100644 --- a/cockpit/chat/messages/angular/src/app/messages.component.ts +++ b/cockpit/chat/messages/angular/src/app/messages.component.ts @@ -11,6 +11,7 @@ import { } from '@threadplane/chat'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { injectAgent } from '@threadplane/langgraph'; +import { MESSAGES_AGENT, type MessagesState } from './agent-ref'; /** * MessagesComponent demonstrates the chat message primitives from @threadplane/chat. @@ -84,7 +85,9 @@ import { injectAgent } from '@threadplane/langgraph'; `, }) export class MessagesComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(MESSAGES_AGENT); + // Typed read: prove MessagesState flows through DI. + protected readonly _typedState: MessagesState = this.agent.value(); protected readonly messageContent = messageContent; diff --git a/cockpit/langgraph/streaming/angular/src/app/agent-ref.ts b/cockpit/langgraph/streaming/angular/src/app/agent-ref.ts new file mode 100644 index 00000000..e4e80765 --- /dev/null +++ b/cockpit/langgraph/streaming/angular/src/app/agent-ref.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; + +/** State shape for the streaming cockpit (LangGraph MessagesState). */ +export interface StreamingState { + messages: unknown[]; +} + +/** + * Typed DI handle for the streaming agent. + * Wire with `provideAgent(STREAMING_AGENT, { ... })` and inject with + * `injectAgent(STREAMING_AGENT)` to get `LangGraphAgent`. + */ +export const STREAMING_AGENT = createAgentRef('streaming'); diff --git a/cockpit/langgraph/streaming/angular/src/app/app.config.ts b/cockpit/langgraph/streaming/angular/src/app/app.config.ts index 67b6e650..cfa0f2ef 100644 --- a/cockpit/langgraph/streaming/angular/src/app/app.config.ts +++ b/cockpit/langgraph/streaming/angular/src/app/app.config.ts @@ -3,10 +3,11 @@ import { ApplicationConfig } from '@angular/core'; import { provideAgent } from '@threadplane/langgraph'; import { provideChat } from '@threadplane/chat'; import { environment } from '../environments/environment'; +import { STREAMING_AGENT } from './agent-ref'; export const appConfig: ApplicationConfig = { providers: [ - provideAgent({ + provideAgent(STREAMING_AGENT, { apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }), diff --git a/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts b/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts index b2504cb7..7015c0d6 100644 --- a/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts +++ b/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts @@ -2,6 +2,7 @@ import { Component } from '@angular/core'; import { ChatComponent, ChatWelcomeSuggestionComponent } from '@threadplane/chat'; import { injectAgent } from '@threadplane/langgraph'; +import { STREAMING_AGENT, type StreamingState } from './agent-ref'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; const WELCOME_SUGGESTIONS = [ @@ -37,7 +38,9 @@ const WELCOME_SUGGESTIONS = [ `, }) export class StreamingComponent { - protected readonly agent = injectAgent(); + protected readonly agent = injectAgent(STREAMING_AGENT); + // Typed read: prove StreamingState flows through DI. + protected readonly _typedState: StreamingState = this.agent.value(); protected readonly suggestions = WELCOME_SUGGESTIONS; protected send(text: string): void { diff --git a/examples/chat/angular/src/app/shell/agent-ref.ts b/examples/chat/angular/src/app/shell/agent-ref.ts new file mode 100644 index 00000000..023af1b0 --- /dev/null +++ b/examples/chat/angular/src/app/shell/agent-ref.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +import { createAgentRef } from '@threadplane/chat'; + +/** + * State shape for the canonical chat demo agent. + * Mirrors the Python graph's `State` TypedDict in + * `examples/chat/python/src/graph.py`. + */ +export interface DemoState { + messages: unknown[]; + model: string | null; + reasoning_effort: string | null; + gen_ui_mode: string | null; +} + +/** + * Typed DI handle for the canonical demo agent. + * Wire with `provideAgent(DEMO_AGENT_REF, () => ({ ... }))` and inject with + * `injectAgent(DEMO_AGENT_REF)` to get `LangGraphAgent`. + */ +export const DEMO_AGENT_REF = createAgentRef('demo'); diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index 27f3f95d..a3a5d148 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -14,6 +14,7 @@ import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { filter, map, startWith } from 'rxjs/operators'; import { injectAgent, provideAgent, LangGraphThreadsAdapter, refreshOnRunEnd } from '@threadplane/langgraph'; +import { DEMO_AGENT_REF, type DemoState } from './agent-ref'; import { ThreadplaneTelemetryService } from '@threadplane/telemetry/browser'; import { ChatInterruptPanelComponent, @@ -90,7 +91,7 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } { // AGENT singleton is first constructed), not at module-load. This matters // for `clientOptions` below — the e2e flag in localStorage is only reliably // readable once the app is running, after bootstrap. - provideAgent(() => ({ + provideAgent(DEMO_AGENT_REF, () => ({ apiUrl: environment.langGraphApiUrl, assistantId: environment.assistantId, // Production keeps the SDK's default connect-retry budget. e2e specs that @@ -448,7 +449,7 @@ export class DemoShell { this.telemetry, () => this.model(), ); - const a = injectAgent(); + const a = injectAgent(DEMO_AGENT_REF); void this.telemetry.capture('ngaf:browser_chat_init', { surface: TELEMETRY_SURFACE }); const orig = a.submit.bind(a); (a as { submit: typeof a.submit }).submit = (async ( @@ -471,6 +472,9 @@ export class DemoShell { return a; })(); + /** Typed read: proves DemoState flows through DI. Not used at runtime. */ + protected readonly _demoState: DemoState = this.agent.value(); + protected onModeChange(next: DemoMode | string): void { // Preserve the active thread across mode switches: /embed/abc → // /popup/abc keeps the conversation visible in the new chrome. From 0a758755c136f8956267009e183cf59288a563d1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 11:56:00 -0700 Subject: [PATCH 21/24] =?UTF-8?q?docs(langgraph),chat:=20fix=20inject-agen?= =?UTF-8?q?t.mdx=20examples=20(add=20required=20assistantId,=20drop=20dead?= =?UTF-8?q?=20link)=20+=20document=20CompatibleProps=20optional=E2=86=92re?= =?UTF-8?q?quired=20edge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review follow-ups: the inject-agent API page showed provideAgent configs missing the required assistantId (throws at runtime) and linked to a non-existent create-agent-ref page. Also document the one CompatibleProps soundness edge (optional schema prop vs required input) — caught by the runtime readiness gate. Co-Authored-By: Claude Fable 5 --- .../content/docs/langgraph/api/inject-agent.mdx | 4 +++- libs/chat/src/lib/client-tools/component-inputs.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/website/content/docs/langgraph/api/inject-agent.mdx b/apps/website/content/docs/langgraph/api/inject-agent.mdx index 6c0cdbc2..b18c26ff 100644 --- a/apps/website/content/docs/langgraph/api/inject-agent.mdx +++ b/apps/website/content/docs/langgraph/api/inject-agent.mdx @@ -45,6 +45,7 @@ export const MY_AGENT = createAgentRef('my-agent'); // Register in app.config.ts providers: provideAgent(MY_AGENT, { apiUrl: 'http://localhost:2024', + assistantId: 'my-graph', threadId: () => localStorage.getItem('threadId') ?? undefined, onThreadId: (id) => localStorage.setItem('threadId', id), }); @@ -55,7 +56,7 @@ readonly chat = injectAgent(MY_AGENT); // chat.state() → MyState ``` -See [`createAgentRef`](/docs/langgraph/api/create-agent-ref) for the full ref API, including the optional `BagTemplate` second generic for typed interrupt payloads. +`createAgentRef(debugName?)` is exported from `@threadplane/chat`; the ref carries the state shape `T`. For typed interrupt payloads, pass the `BagTemplate` as the second generic at the inject site — `injectAgent(ref)` — as shown in the [Interrupts guide](/docs/langgraph/guides/interrupts). Pair it with `provideAgent()` at bootstrap to configure the API URL, assistant id, thread persistence, and transport: @@ -71,6 +72,7 @@ bootstrapApplication(AppComponent, { providers: [ provideAgent(MY_AGENT, { apiUrl: 'http://localhost:2024', + assistantId: 'my-graph', threadId: () => localStorage.getItem('threadId') ?? undefined, onThreadId: (id) => localStorage.setItem('threadId', id), }), diff --git a/libs/chat/src/lib/client-tools/component-inputs.ts b/libs/chat/src/lib/client-tools/component-inputs.ts index 53d2e1b0..e7920b37 100644 --- a/libs/chat/src/lib/client-tools/component-inputs.ts +++ b/libs/chat/src/lib/client-tools/component-inputs.ts @@ -27,7 +27,15 @@ export type ComponentInputs = { /** STRICT: every prop the schema PRODUCES must be a declared input with an * assignable type. FLEXIBLE: the component may declare extra inputs the schema * doesn't fill. A schema key absent from `Inputs` maps to `never`, so its - * (non-never) value fails assignment and the error pins to that prop. */ + * (non-never) value fails assignment and the error pins to that prop. + * + * This mapped type is homomorphic over `keyof Out`, so it preserves the + * optionality (`?`) of each schema prop. A consequence: an OPTIONAL schema prop + * is accepted against a `required` component input — the compiler cannot know + * the model will actually supply it. That residual case (required input not + * guaranteed by the schema) is caught at runtime by the schema-readiness mount + * gate, which holds the fallback until the streamed props validate. Compile + * time blocks structural mismatches; runtime blocks missing-but-required props. */ export type CompatibleProps = { [K in keyof Out]: K extends keyof Inputs ? Inputs[K] : never; }; From 592419d21513ef4c38ebdfbedd2b8d57b5699bbf Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 11:57:37 -0700 Subject: [PATCH 22/24] docs(api): regenerate api-docs for new chat exports (createAgentRef/AgentRef/ToolArgs/ViewProps/AnyFunctionToolDef) + adapter ref overloads Co-Authored-By: Claude Fable 5 --- .../content/docs/ag-ui/api/api-docs.json | 22 +- .../content/docs/chat/api/api-docs.json | 279 +++++++++++++----- .../content/docs/langgraph/api/api-docs.json | 20 +- .../content/docs/render/api/api-docs.json | 16 +- 4 files changed, 236 insertions(+), 101 deletions(-) diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index cf9ae198..509e137b 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -457,7 +457,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -556,11 +556,11 @@ { "name": "injectAgent", "kind": "function", - "description": "Injects the AG-UI agent from Angular's dependency injection container.\nUse this in components or services provided via `provideAgent()` (or\n`provideFakeAgent()`).\n\nReturns an `AgUiAgent` — the runtime-neutral `Agent` contract plus the\nAG-UI-specific `customEvents` signal — so `customEvents` is reachable\ndirectly, without casting.", - "signature": "injectAgent(): AgUiAgent", + "description": "Injects the AG-UI agent from Angular's dependency injection container.\nUse this in components or services provided via `provideAgent()` (or\n`provideFakeAgent()`).\n\nReturns an `AgUiAgent` — the runtime-neutral `Agent` contract plus the\nAG-UI-specific `customEvents` signal — so `customEvents` is reachable\ndirectly, without casting.\n\n**Typed state via AgentRef.** Pass the same ref that was supplied to\n`provideAgent(ref, …)` to carry the state type through DI without repeating\nthe generic at every call site:\n\n```ts\nconst agent = injectAgent(TRIP); // AgUiAgent\n```\n\nThe no-arg form defaults to `AgUiAgent>`.", + "signature": "injectAgent(): AgUiAgent<>", "params": [], "returns": { - "type": "AgUiAgent", + "type": "AgUiAgent<>", "description": "" }, "examples": [] @@ -568,9 +568,15 @@ { "name": "provideAgent", "kind": "function", - "description": "Provides an Agent instance wired through HttpAgent and toAgent.\nConstructs an HttpAgent from config and wraps it in the runtime-neutral\nAgent contract via toAgent(). Returns a provider array suitable for\nbootstrapApplication or TestBed.configureTestingModule().\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services or route params.", - "signature": "provideAgent(configOrFactory: AgentConfig | () => AgentConfig): Provider[]", + "description": "Provides an Agent instance wired through HttpAgent and toAgent.\nConstructs an HttpAgent from config and wraps it in the runtime-neutral\nAgent contract via toAgent(). Returns a provider array suitable for\nbootstrapApplication or TestBed.configureTestingModule().\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services or route params.\n\n**Typed state via AgentRef.** Pass a typed ref as the first argument to flow\nthe state shape from `provideAgent` to `injectAgent` without repeating the\ngeneric at every call site:\n\n```ts\ninterface TripState { day: number; places: string[]; }\nexport const TRIP = createAgentRef('trip');\n// app.config.ts:\nproviders: [provideAgent(TRIP, { url: 'http://localhost:8000/agent' })]\n// component:\nconst agent = injectAgent(TRIP); // AgUiAgent\n```", + "signature": "provideAgent(ref: AgentRef, configOrFactory: AgentConfig | () => AgentConfig): Provider[]", "params": [ + { + "name": "ref", + "type": "AgentRef", + "description": "", + "optional": false + }, { "name": "configOrFactory", "type": "AgentConfig | () => AgentConfig", @@ -607,7 +613,7 @@ "name": "toAgent", "kind": "function", "description": "Wraps an AG-UI AbstractAgent into the runtime-neutral Agent contract.\n\nThe adapter subscribes to source.subscribe({ onEvent }) and reduces every\nevent into the produced Agent's signals. submit() optimistically appends the\nuser message to both our signals and the source agent's internal message\nlist, then calls source.runAgent(). stop() calls source.abortRun().\n\nSubscription cleanup: the returned Agent does NOT manage its own lifetime.\nCallers using DI should rely on the provider's destroy hook; direct callers\nof toAgent() should treat the returned object's lifecycle as tied to the\nagent instance they constructed. The subscriber registered via\nsource.subscribe() will fire for the lifetime of source.", - "signature": "toAgent(source: AbstractAgent<>, options: ToAgentOptions): AgUiAgent", + "signature": "toAgent(source: AbstractAgent<>, options: ToAgentOptions): AgUiAgent<>", "params": [ { "name": "source", @@ -623,7 +629,7 @@ } ], "returns": { - "type": "AgUiAgent", + "type": "AgUiAgent<>", "description": "" }, "examples": [] diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 19ef10b7..afb2db4b 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1441,7 +1441,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -1592,7 +1592,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2115,7 +2115,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2321,7 +2321,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2448,7 +2448,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2496,7 +2496,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2670,7 +2670,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -2797,7 +2797,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -3298,7 +3298,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -3393,7 +3393,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal> | AgentWithHistory> | null>", "description": "", "optional": false }, @@ -3639,7 +3639,7 @@ }, { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4191,7 +4191,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4239,7 +4239,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4372,7 +4372,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4504,7 +4504,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -4603,7 +4603,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>>", "description": "", "optional": false }, @@ -5356,7 +5356,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -5471,6 +5471,20 @@ ], "examples": [] }, + { + "name": "AgentRef", + "kind": "interface", + "description": "A typed handle that threads a state shape through Angular DI from\n `provideAgent(ref, …)` to `injectAgent(ref)` without per-call-site\n restatement of the generic.", + "properties": [ + { + "name": "token", + "type": "InjectionToken>", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "AgentRuntimeTelemetryPayload", "kind": "interface", @@ -5662,7 +5676,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -5699,6 +5713,38 @@ ], "examples": [] }, + { + "name": "AnyFunctionToolDef", + "kind": "interface", + "description": "Bivariant union member used only for registry storage/iteration. The handler\n param is `any` (NOT `never`): `any` is simultaneously a supertype any precise\n `FunctionToolDef` is assignable to under `strictFunctionTypes`, AND\n callable by internal code that has narrowed by `kind` and parsed runtime args.\n A `never` param would satisfy the former but break the latter.", + "properties": [ + { + "name": "description", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "handler", + "type": "(args: any) => unknown", + "description": "", + "optional": false + }, + { + "name": "kind", + "type": "\"function\"", + "description": "", + "optional": false + }, + { + "name": "schema", + "type": "StandardSchemaV1<>", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "AskToolDef", "kind": "interface", @@ -5706,7 +5752,7 @@ "properties": [ { "name": "component", - "type": "Type", + "type": "Type", "description": "", "optional": false }, @@ -5724,7 +5770,7 @@ }, { "name": "schema", - "type": "StandardSchemaV1<>", + "type": "S", "description": "", "optional": false } @@ -5986,12 +6032,12 @@ "methods": [ { "name": "connect", - "signature": "connect(agent: Agent): void", + "signature": "connect(agent: Agent<>): void", "description": "Wire the coordinator to an agent: ship the catalog, run function tools, auto-ack view tools.\n MUST be called inside an injection context (sets up effects). Safe no-op if the agent lacks\n the clientTools capability.", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false } @@ -5999,12 +6045,12 @@ }, { "name": "handleRenderEvent", - "signature": "handleRenderEvent(agent: Agent, event: RenderEvent): void", + "signature": "handleRenderEvent(agent: Agent<>, event: RenderEvent): void", "description": "Handle a render event bubbled up from a mounted view/ask component (resolves `ask` results).", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false }, @@ -6157,7 +6203,7 @@ { "name": "FunctionToolDef", "kind": "interface", - "description": "", + "description": "Precise authored function tool — what `action()` returns. Carries the schema\n `S` and the handler's resolved return type `R`.", "properties": [ { "name": "description", @@ -6167,7 +6213,7 @@ }, { "name": "handler", - "type": "(args: StandardSchemaInferOutput) => unknown", + "type": "(args: StandardSchemaInferOutput) => R | Promise", "description": "", "optional": false }, @@ -6620,6 +6666,20 @@ ], "examples": [] }, + { + "name": "StandardSchemaV1", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "~standard", + "type": "StandardSchemaProps", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "Subagent", "kind": "interface", @@ -6904,7 +6964,7 @@ "properties": [ { "name": "component", - "type": "Type", + "type": "Type", "description": "", "optional": false }, @@ -6922,7 +6982,7 @@ }, { "name": "schema", - "type": "StandardSchemaV1<>", + "type": "S", "description": "", "optional": false } @@ -7010,7 +7070,7 @@ "name": "ClientToolDef", "kind": "type", "description": "A client tool the model can call; executed in the browser.", - "signature": "FunctionToolDef | ViewToolDef | AskToolDef", + "signature": "AnyFunctionToolDef | ViewToolDef | AskToolDef", "examples": [] }, { @@ -7069,6 +7129,20 @@ "signature": "\"user\" | \"assistant\" | \"system\" | \"tool\"", "examples": [] }, + { + "name": "StandardSchemaInferInput", + "kind": "type", + "description": "", + "signature": "NonNullable[\"input\"]", + "examples": [] + }, + { + "name": "StandardSchemaInferOutput", + "kind": "type", + "description": "", + "signature": "NonNullable[\"output\"]", + "examples": [] + }, { "name": "SubagentStatus", "kind": "type", @@ -7083,6 +7157,13 @@ "signature": "unknown", "examples": [] }, + { + "name": "ToolArgs", + "kind": "type", + "description": "Inferred argument type for a schema (alias of StandardSchemaInferOutput).", + "signature": "StandardSchemaInferOutput", + "examples": [] + }, { "name": "ToolCallStatus", "kind": "type", @@ -7097,6 +7178,13 @@ "signature": "\"pending\" | \"running\" | \"done\" | \"error\"", "examples": [] }, + { + "name": "ViewProps", + "kind": "type", + "description": "Reverse helper: derive a component's input prop types FROM a schema, so a\n component authored straight from the schema is guaranteed compatible.", + "signature": "Prettify>", + "examples": [] + }, { "name": "ViewRegistry", "kind": "type", @@ -7210,64 +7298,68 @@ { "name": "action", "kind": "function", - "description": "Async function tool — its resolved return value becomes the tool result.", - "signature": "action(description: string, schema: S, handler: (args: StandardSchemaInferOutput) => unknown): FunctionToolDef", + "description": "Declare an async function tool the model can call; its resolved return value\nbecomes the tool result shipped back to the model.", + "signature": "action(description: string, schema: S, handler: (args: StandardSchemaInferOutput) => R | Promise): FunctionToolDef", "params": [ { "name": "description", "type": "string", - "description": "", + "description": "Natural-language description the model sees.", "optional": false }, { "name": "schema", "type": "S", - "description": "", + "description": "Standard Schema (e.g. a Zod object) for the arguments; the\n handler's argument type is inferred from it.", "optional": false }, { "name": "handler", - "type": "(args: StandardSchemaInferOutput) => unknown", - "description": "", + "type": "(args: StandardSchemaInferOutput) => R | Promise", + "description": "Runs in the browser when the model calls the tool; its return\n type `R` is carried on the resulting FunctionToolDef.", "optional": false } ], "returns": { - "type": "FunctionToolDef", + "type": "FunctionToolDef", "description": "" }, - "examples": [] + "examples": [ + "```ts\nconst move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay);\nconst registry = tools({ move_stop: move });\n```" + ] }, { "name": "ask", "kind": "function", - "description": "Interactive (HITL) component tool — the value it emits becomes the result.", - "signature": "ask(description: string, schema: StandardSchemaV1<>, component: Type): ClientToolDef", + "description": "Interactive (human-in-the-loop) component tool — the model fills the\ncomponent's props from the schema's output; the value the component emits\nback to the framework becomes the tool result sent to the model.\n\nThe component's signal inputs are checked against the schema output type\n(strict-but-flexible: every schema key must be a declared input with an\nassignable type; the component may declare extra inputs the schema doesn't\nfill). Author the component with `ViewProps` to derive input\nprop types directly from the schema.", + "signature": "ask(description: string, schema: S, component: AcceptComponent): AskToolDef", "params": [ { "name": "description", "type": "string", - "description": "", + "description": "Natural-language description the model sees.", "optional": false }, { "name": "schema", - "type": "StandardSchemaV1<>", - "description": "", + "type": "S", + "description": "Standard Schema defining the props the model must supply.", "optional": false }, { "name": "component", - "type": "Type", - "description": "", + "type": "AcceptComponent", + "description": "Angular component whose signal inputs must be compatible with\n the schema output. A type-level error is reported here when they diverge.", "optional": false } ], "returns": { - "type": "ClientToolDef", + "type": "AskToolDef", "description": "" }, - "examples": [] + "examples": [ + "```ts\nconst schema = z.object({ question: z.string(), options: z.array(z.string()) });\ntype Inputs = ViewProps;\n\n\\@Component({ ... })\nclass ChoiceCardComponent {\n question = input.required();\n options = input.required();\n // Emits the chosen option back to the model.\n}\n\nconst choice = ask('Ask the user to choose', schema, ChoiceCardComponent);\nconst registry = tools({ pick_option: choice });\n```" + ] }, { "name": "buildA2uiActionMessage", @@ -7306,6 +7398,27 @@ }, "examples": [] }, + { + "name": "createAgentRef", + "kind": "function", + "description": "Create a typed agent handle.", + "signature": "createAgentRef(debugName: string): AgentRef", + "params": [ + { + "name": "debugName", + "type": "string", + "description": "Optional name shown in Angular DI error messages.", + "optional": true + } + ], + "returns": { + "type": "AgentRef", + "description": "" + }, + "examples": [ + "```ts\ninterface TripState { day: number; places: string[]; }\nexport const TRIP = createAgentRef('trip');\n// app.config.ts: provideAgent(TRIP, { assistantId: 'trip' })\n// component: const agent = injectAgent(TRIP); // LangGraphAgent\n```" + ] + }, { "name": "createClientToolsCoordinator", "kind": "function", @@ -7441,11 +7554,11 @@ "name": "executeFunctionTool", "kind": "function", "description": "Validate args, run the handler, and normalize the outcome to a ClientToolResult.", - "signature": "executeFunctionTool(def: FunctionToolDef<>, rawArgs: unknown): Promise", + "signature": "executeFunctionTool(def: AnyFunctionToolDef, rawArgs: unknown): Promise", "params": [ { "name": "def", - "type": "FunctionToolDef<>", + "type": "AnyFunctionToolDef", "description": "", "optional": false }, @@ -7504,11 +7617,11 @@ "name": "getInterrupt", "kind": "function", "description": "", - "signature": "getInterrupt(agent: Agent): AgentInterrupt | undefined", + "signature": "getInterrupt(agent: Agent<>): AgentInterrupt | undefined", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false } @@ -7599,11 +7712,11 @@ "name": "isTyping", "kind": "function", "description": "", - "signature": "isTyping(agent: Agent): boolean", + "signature": "isTyping(agent: Agent<>): boolean", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false } @@ -7656,7 +7769,7 @@ "name": "mockAgent", "kind": "function", "description": "", - "signature": "mockAgent(opts: MockAgentOptions): MockAgent", + "signature": "mockAgent(opts: MockAgentOptions): MockAgent<>", "params": [ { "name": "opts", @@ -7666,7 +7779,7 @@ } ], "returns": { - "type": "MockAgent", + "type": "MockAgent<>", "description": "" }, "examples": [] @@ -7712,13 +7825,13 @@ { "name": "provideChat", "kind": "function", - "description": "", + "description": "Bootstrap `@threadplane/chat` in an Angular application or standalone\ncomponent tree.\n\nCall this once inside `bootstrapApplication` (or the `providers` array of a\nroot `ApplicationConfig`). It registers the shared ChatConfig token\nso every chat component in the tree can read the render registry, avatar\nlabel, and assistant display name without explicit prop threading.\n\nA license check is fired asynchronously on every call (it never throws; a\nwatermark is shown in non-commercial builds when no valid token is supplied).", "signature": "provideChat(config: ChatConfig): EnvironmentProviders", "params": [ { "name": "config", "type": "ChatConfig", - "description": "", + "description": "Options bag that controls the chat feature set:\n - `renderRegistry` — shared AngularRegistry wiring tool-view\n components to their names; pass the value returned by\n `defineAngularRegistry` from `\\@threadplane/render`.\n - `avatarLabel` — short label shown in the AI avatar bubble (default `\"A\"`).\n - `assistantName` — display name shown above assistant messages\n (default `\"Assistant\"`).\n - `license` — signed token from threadplane.ai; omit in development.", "optional": false } ], @@ -7726,7 +7839,9 @@ "type": "EnvironmentProviders", "description": "" }, - "examples": [] + "examples": [ + "```ts\n// main.ts\nimport { bootstrapApplication } from '@angular/platform-browser';\nimport { provideChat } from '@threadplane/chat';\nimport { defineAngularRegistry, provideRender } from '@threadplane/render';\nimport { DayCardComponent } from './day-card.component';\n\nconst registry = defineAngularRegistry({ day_card: DayCardComponent });\n\nbootstrapApplication(AppComponent, {\n providers: [\n provideChat({ renderRegistry: registry, avatarLabel: 'AI' }),\n provideRender({ registry }),\n ],\n});\n```" + ] }, { "name": "renderMarkdown", @@ -7757,11 +7872,11 @@ "name": "startClientToolExecutor", "kind": "function", "description": "Watches the agent's pending client tool calls and auto-runs FUNCTION tools,\nresolving each with its result. View/ask (component) tools are handled by the\nrendering layer, not here. No-op if the agent lacks the clientTools\ncapability. MUST be called in an injection context (sets up an effect).", - "signature": "startClientToolExecutor(agent: Agent, registry: ClientToolRegistry): void", + "signature": "startClientToolExecutor(agent: Agent<>, registry: ClientToolRegistry): void", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false }, @@ -7801,11 +7916,11 @@ "name": "submitMessage", "kind": "function", "description": "Submits a trimmed message to the agent.\nReturns the trimmed string on success, or `null` if the input was empty.", - "signature": "submitMessage(agent: Agent, text: string): string | null", + "signature": "submitMessage(agent: Agent<>, text: string): string | null", "params": [ { "name": "agent", - "type": "Agent", + "type": "Agent<>", "description": "", "optional": false }, @@ -7863,21 +7978,23 @@ { "name": "tools", "kind": "function", - "description": "Collect named client tools into a frozen registry (the key is the tool name).", - "signature": "tools(map: Record): ClientToolRegistry", + "description": "Collect named client tools into a frozen, name-keyed registry.\n\nThe overload is generic over the entire map (`const M`) so that each tool's\nprecise type (FunctionToolDef``, ViewToolDef``, or\nAskToolDef``) and every literal key are preserved in the\nClientToolRegistry passed to `provideChat`. This lets downstream\nconsumers look up individual tools without losing generic information.", + "signature": "tools(map: M): Readonly", "params": [ { "name": "map", - "type": "Record", - "description": "", + "type": "M", + "description": "An object literal mapping tool names to tool definitions created\n by action, view, or ask.", "optional": false } ], "returns": { - "type": "ClientToolRegistry", + "type": "Readonly", "description": "" }, - "examples": [] + "examples": [ + "```ts\nconst move = action('Move a stop', z.object({ fromDay: z.number() }), (a) => a.fromDay);\nconst dayCard = view('Show a day card', z.object({ label: z.string() }), DayCardComponent);\n\nconst registry = tools({ move_stop: move, day_card: dayCard });\n// registry.move_stop is FunctionToolDef<...>\n// registry.day_card is ViewToolDef<...>\n```" + ] }, { "name": "toRenderRegistry", @@ -7926,33 +8043,35 @@ { "name": "view", "kind": "function", - "description": "Render-only component tool — the model fills its props; auto-acknowledged.", - "signature": "view(description: string, schema: StandardSchemaV1<>, component: Type): ClientToolDef", + "description": "Render-only component tool — the model fills the component's props from the\nschema's output; the tool call is auto-acknowledged once the component mounts.\n\nThe component's signal inputs are checked against the schema output type\n(strict-but-flexible: every schema key must be a declared input with an\nassignable type; the component may declare extra inputs the schema doesn't fill).\nAuthor the component with `ViewProps` as the input type set to\nguarantee the shapes stay aligned.", + "signature": "view(description: string, schema: S, component: AcceptComponent): ViewToolDef", "params": [ { "name": "description", "type": "string", - "description": "", + "description": "Natural-language description the model sees.", "optional": false }, { "name": "schema", - "type": "StandardSchemaV1<>", - "description": "", + "type": "S", + "description": "Standard Schema defining the props the model must supply.", "optional": false }, { "name": "component", - "type": "Type", - "description": "", + "type": "AcceptComponent", + "description": "Angular component whose signal inputs must be compatible with\n the schema output. A type-level error is reported here when they diverge.", "optional": false } ], "returns": { - "type": "ClientToolDef", + "type": "ViewToolDef", "description": "" }, - "examples": [] + "examples": [ + "```ts\nconst schema = z.object({ label: z.string(), day: z.number() });\ntype Inputs = ViewProps; // { label: string; day: number }\n\n\\@Component({ ... })\nclass DayCardComponent {\n label = input.required();\n day = input.required();\n}\n\nconst dayCard = view('Show a day card', schema, DayCardComponent);\nconst registry = tools({ day_card: dayCard });\n```" + ] }, { "name": "views", @@ -8120,7 +8239,7 @@ "name": "runAgentConformance", "kind": "function", "description": "Runs a suite of contract conformance assertions against a factory that\nproduces a fresh Agent. Adapter packages should call this in their\nown test suites to verify the contract is satisfied.", - "signature": "runAgentConformance(label: string, factory: () => Agent): void", + "signature": "runAgentConformance(label: string, factory: () => Agent<>): void", "params": [ { "name": "label", @@ -8130,7 +8249,7 @@ }, { "name": "factory", - "type": "() => Agent", + "type": "() => Agent<>", "description": "", "optional": false } @@ -8145,7 +8264,7 @@ "name": "runAgentWithHistoryConformance", "kind": "function", "description": "Conformance suite for AgentWithHistory implementations.\n\nRuns the base Agent conformance suite, then verifies the history\nsignal is present and returns an array of AgentCheckpoint-shaped entries.", - "signature": "runAgentWithHistoryConformance(label: string, factory: (seed: object) => AgentWithHistory): void", + "signature": "runAgentWithHistoryConformance(label: string, factory: (seed: object) => AgentWithHistory<>): void", "params": [ { "name": "label", @@ -8155,7 +8274,7 @@ }, { "name": "factory", - "type": "(seed: object) => AgentWithHistory", + "type": "(seed: object) => AgentWithHistory<>", "description": "", "optional": false } diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index 80edbb2e..5aeb63ab 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -1589,7 +1589,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -2011,7 +2011,7 @@ }, { "name": "state", - "type": "Signal>", + "type": "Signal", "description": "", "optional": false }, @@ -2432,11 +2432,11 @@ { "name": "injectAgent", "kind": "function", - "description": "Retrieve the LangGraph-backed Agent from the current Angular injection context.\n\nMirrors `@threadplane/ag-ui`'s `injectAgent()` so consumer code is identical\nregardless of which adapter is wired in `app.config.ts`. The agent is a\nsingleton scoped to the injector that called `provideAgent()` — re-provide\nin a child component's `providers: []` to scope a different agent to that\nsubtree (Angular's hierarchical DI handles the rest).", - "signature": "injectAgent(): LangGraphAgent", + "description": "Retrieve the LangGraph-backed Agent from the current Angular injection context.\n\nMirrors `@threadplane/ag-ui`'s `injectAgent()` so consumer code is identical\nregardless of which adapter is wired in `app.config.ts`. The agent is a\nsingleton scoped to the injector that called `provideAgent()` — re-provide\nin a child component's `providers: []` to scope a different agent to that\nsubtree (Angular's hierarchical DI handles the rest).\n\n**Typed state via AgentRef.** Pass the same ref that was supplied to\n`provideAgent(ref, …)` to carry the state type through DI without repeating\nthe generic at every call site:\n\n```ts\nconst agent = injectAgent(TRIP); // LangGraphAgent\n```\n\nThe no-arg form defaults to `LangGraphAgent>`.", + "signature": "injectAgent(): LangGraphAgent>", "params": [], "returns": { - "type": "LangGraphAgent", + "type": "LangGraphAgent>", "description": "" }, "examples": [] @@ -2463,9 +2463,15 @@ { "name": "provideAgent", "kind": "function", - "description": "Wire the LangGraph adapter into Angular's dependency injection.\n\nRegisters a singleton `LangGraphAgent` constructed from `config`. Retrieve it\nin any component with `injectAgent()`. Provide this at the application root\n(`app.config.ts`) for an app-wide agent.\n\nTo use a different agent in a component subtree, re-provide\n`provideAgent({...})` in that component's `providers: []` array —\nAngular's hierarchical DI scopes the singleton accordingly.\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services, route params, or\ncomponent-scoped signals:\n\n```ts\nproviders: [\n provideAgent(() => {\n const route = inject(ActivatedRoute);\n return { assistantId: 'chat', threadId: toSignal(route.paramMap) };\n }),\n];\n```", - "signature": "provideAgent(configOrFactory: AgentConfig | () => AgentConfig): Provider[]", + "description": "Wire the LangGraph adapter into Angular's dependency injection.\n\nRegisters a singleton `LangGraphAgent` constructed from `config`. Retrieve it\nin any component with `injectAgent()`. Provide this at the application root\n(`app.config.ts`) for an app-wide agent.\n\nTo use a different agent in a component subtree, re-provide\n`provideAgent({...})` in that component's `providers: []` array —\nAngular's hierarchical DI scopes the singleton accordingly.\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services, route params, or\ncomponent-scoped signals:\n\n```ts\nproviders: [\n provideAgent(() => {\n const route = inject(ActivatedRoute);\n return { assistantId: 'chat', threadId: toSignal(route.paramMap) };\n }),\n];\n```\n\n**Typed state via AgentRef.** Pass a typed ref as the first argument to flow\nthe state shape from `provideAgent` to `injectAgent` without repeating the\ngeneric at every call site:\n\n```ts\nexport const TRIP = createAgentRef('trip');\n// app.config.ts:\nproviders: [provideAgent(TRIP, { assistantId: 'trip-graph' })]\n// component:\nconst agent = injectAgent(TRIP); // LangGraphAgent\n```", + "signature": "provideAgent(ref: AgentRef, configOrFactory: AgentConfig | () => AgentConfig): Provider[]", "params": [ + { + "name": "ref", + "type": "AgentRef", + "description": "", + "optional": false + }, { "name": "configOrFactory", "type": "AgentConfig | () => AgentConfig", diff --git a/apps/website/content/docs/render/api/api-docs.json b/apps/website/content/docs/render/api/api-docs.json index 0263921f..951646bf 100644 --- a/apps/website/content/docs/render/api/api-docs.json +++ b/apps/website/content/docs/render/api/api-docs.json @@ -702,13 +702,13 @@ { "name": "defineAngularRegistry", "kind": "function", - "description": "", + "description": "Build an AngularRegistry from a plain object mapping tool-call names\nto Angular components (or fully specified RenderViewEntry objects).\n\nThe returned registry is consumed by both `provideRender` (to drive\ndynamic component rendering) and `provideChat` (via `renderRegistry`) so\nthat a single `defineAngularRegistry` call wires both layers.\n\n**Entry forms**\n- Bare `Type` — the component is paired with the built-in\n `DefaultFallbackComponent` while its props are still streaming.\n- `RenderViewEntry` object — lets you supply a custom `fallback` component,\n an optional Standard Schema (`schema`) used as a mount-readiness gate, and\n an optional `description` for model-facing tool registration.\n\n**Registry accessor**\nThe returned object exposes a single `getEntry(name: string)` accessor that\nreturns the fully-normalized NormalizedEntry (component + fallback +\noptional schema + optional description) or `undefined` when the name is not\nregistered. Use `names()` to enumerate all registered names.", "signature": "defineAngularRegistry(componentMap: RegistryInput): AngularRegistry", "params": [ { "name": "componentMap", "type": "RegistryInput", - "description": "", + "description": "Object whose keys are tool-call names and whose values\n are either bare Angular component classes or RenderViewEntry objects.", "optional": false } ], @@ -716,7 +716,9 @@ "type": "AngularRegistry", "description": "" }, - "examples": [] + "examples": [ + "```ts\nimport { defineAngularRegistry } from '@threadplane/render';\nimport { DayCardComponent } from './day-card.component';\nimport { LoadingSpinnerComponent } from './loading-spinner.component';\nimport { z } from 'zod';\n\nexport const registry = defineAngularRegistry({\n // Bare component — uses DefaultFallbackComponent while streaming.\n summary_card: SummaryCardComponent,\n\n // Full entry — custom fallback + schema-gated mounting.\n day_card: {\n component: DayCardComponent,\n fallback: LoadingSpinnerComponent,\n schema: z.object({ label: z.string(), day: z.number() }),\n description: 'Renders a single itinerary day card.',\n },\n});\n\n// Look up a registered entry at runtime:\nconst entry = registry.getEntry('day_card'); // NormalizedEntry | undefined\n```" + ] }, { "name": "injectRenderHost", @@ -758,13 +760,13 @@ { "name": "provideRender", "kind": "function", - "description": "", + "description": "Bootstrap `@threadplane/render` in an Angular application or standalone\ncomponent tree.\n\nRegisters the shared RenderConfig token and the internal\n`RenderLifecycleService` that coordinates mount/unmount events across\ndynamically rendered components. Call this once alongside `provideChat` in\n`bootstrapApplication` (or the root `ApplicationConfig`).", "signature": "provideRender(config: RenderConfig): EnvironmentProviders", "params": [ { "name": "config", "type": "RenderConfig", - "description": "", + "description": "Options bag that controls the render feature set:\n - `registry` — component registry returned by defineAngularRegistry;\n maps tool-call names to Angular components.\n - `store` — optional `StateStore` for `\\@json-render/core` state binding.\n - `functions` — optional map of computed functions available inside specs.\n - `handlers` — optional map of event handlers triggered by spec actions.", "optional": false } ], @@ -772,7 +774,9 @@ "type": "EnvironmentProviders", "description": "" }, - "examples": [] + "examples": [ + "```ts\n// main.ts\nimport { bootstrapApplication } from '@angular/platform-browser';\nimport { defineAngularRegistry, provideRender } from '@threadplane/render';\nimport { provideChat } from '@threadplane/chat';\nimport { DayCardComponent } from './day-card.component';\n\nconst registry = defineAngularRegistry({ day_card: DayCardComponent });\n\nbootstrapApplication(AppComponent, {\n providers: [\n provideRender({ registry }),\n provideChat({ renderRegistry: registry }),\n ],\n});\n```" + ] }, { "name": "provideViews", From 4957f3ee23b0856e6ec6f78ec36a5cdfd8efcc5f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 16:50:15 -0700 Subject: [PATCH 23/24] test(examples/ag-ui): match new classified-error copy in error-handling e2e #685 replaced the raw failure text with friendly AGENT_ERROR_MESSAGES copy ("Can't reach the server. Check your connection and try again."), which no longer contains "fail"/"error". Mirror the chat example's assertion so the ag-ui error-handling spec asserts the actual classified copy. Co-Authored-By: Claude Fable 5 --- examples/ag-ui/angular/e2e/error-handling.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/ag-ui/angular/e2e/error-handling.spec.ts b/examples/ag-ui/angular/e2e/error-handling.spec.ts index e17a3c71..107845f6 100644 --- a/examples/ag-ui/angular/e2e/error-handling.spec.ts +++ b/examples/ag-ui/angular/e2e/error-handling.spec.ts @@ -13,7 +13,10 @@ test('error handling: failed stream surfaces an alert and the next send recovers await messageInput(page).fill('say hi briefly'); await sendButton(page).click(); - await expect(page.getByRole('alert')).toContainText(/fail|error/i, { timeout: 15_000 }); + await expect(page.getByRole('alert')).toContainText( + /can't reach|connection|server|interrupted|try again/i, + { timeout: 15_000 }, + ); await page.unroute('**/agent'); await expect(messageInput(page)).toBeEnabled(); From fb1fc151d1a55afebae1660ccaa1b50e257f6d6c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 21:46:48 -0700 Subject: [PATCH 24/24] chore: retrigger Vercel preview deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The required 'Vercel – threadplane' check never posted after repeated branch updates (known Vercel-stall-on-rebase). Empty commit to retrigger. Co-Authored-By: Claude Fable 5