From 08cbc3489981b3652d3fbe11af67df768b1dcd86 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 17 Jun 2026 22:29:50 -0700 Subject: [PATCH 1/7] docs(spec): shared LangGraph client options (single-source retry budget) Co-Authored-By: Claude Fable 5 --- ...-langgraph-shared-client-options-design.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-17-langgraph-shared-client-options-design.md diff --git a/docs/superpowers/specs/2026-06-17-langgraph-shared-client-options-design.md b/docs/superpowers/specs/2026-06-17-langgraph-shared-client-options-design.md new file mode 100644 index 00000000..f6270106 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-langgraph-shared-client-options-design.md @@ -0,0 +1,155 @@ +# Shared LangGraph client options (single-source retry budget) — Design + +**Status:** Approved (brainstorm 2026-06-17) + +**Goal:** Provide one app-wide place to configure the LangGraph SDK client's +retry/tuning options (`LangGraphClientOptions`) so that **both** the streaming +agent transport and the threads adapter obey it — set once, not per subsystem. + +## Background + +PR #677 added `clientOptions.maxRetries` to the agent path +(`provideAgent` → `agent()` → `FetchStreamTransport` → `createLangGraphClient` +→ SDK `callerOptions`) to let a failed stream connect fail fast under test +while keeping the SDK's resilient default in production. + +The threads adapter (`LangGraphThreadsAdapter`) constructs its own client via +`createLangGraphClient(this.config.apiUrl)` **without** options, so thread CRUD +(create / getHistory / getState / updateState) still retries ~15s on a hard +failure. An existing escape hatch — the `LANGGRAPH_CLIENT` injection token — +already lets a consumer inject a fully-built client, but that requires building +the client by hand and is per-subsystem. + +This design adds a single shared knob both subsystems read. + +## Non-goals (YAGNI) + +- No refactor of `apiUrl` wiring (`provideAgent` and `LANGGRAPH_THREADS_CONFIG` + keep taking `apiUrl` independently). +- No change to the SDK default retry behavior in production. +- No new thread-CRUD-abort e2e (would require aborting `/threads`; tracked as an + optional follow-up). +- `LANGGRAPH_CLIENT` (inject a pre-built client) stays as-is. + +## Architecture + +### New DI token (libs/langgraph) + +```ts +// LangGraphClientOptions already exists (agent.types.ts, exported). +export const LANGGRAPH_CLIENT_OPTIONS = + new InjectionToken('LANGGRAPH_CLIENT_OPTIONS'); +``` + +Exported from `libs/langgraph/src/public-api.ts`. Provided once at app root. + +### Precedence helper (pure, testable) + +A small pure function resolves the effective options without reaching into SDK +internals: + +```ts +// libs/langgraph/src/lib/client/resolve-client-options.ts +export function resolveClientOptions( + ...layers: Array +): LangGraphClientOptions | undefined { + for (const layer of layers) if (layer) return layer; + return undefined; +} +``` + +It returns the first defined layer (highest precedence first). Whole-object +precedence (not per-field merge) keeps semantics obvious; `maxRetries` is the +only field today. + +### Read sites + +**Agent** (`agent.fn.ts`) — already in an injection context (it calls +`inject(DestroyRef)` / `inject(AGENT_CONFIG, { optional: true })`). Add: + +```ts +const sharedClientOptions = inject(LANGGRAPH_CLIENT_OPTIONS, { optional: true }); +const clientOptions = resolveClientOptions( + options.clientOptions, // 1. agent({ clientOptions }) call-site + globalConfig?.clientOptions, // 2. provideAgent({ clientOptions }) (AGENT_CONFIG) + sharedClientOptions, // 3. LANGGRAPH_CLIENT_OPTIONS token +); +``` + +`clientOptions` is then passed into the bridge options (already threaded to +`FetchStreamTransport` in #677). The bridge/transport wiring is unchanged. + +**Threads adapter** (`threads-adapter.ts`) — `@Injectable({ providedIn: 'root' })`, +so it can inject the token. On the path where `LANGGRAPH_CLIENT` is **not** +provided: + +```ts +private readonly sharedClientOptions = + inject(LANGGRAPH_CLIENT_OPTIONS, { optional: true }) ?? undefined; +private readonly client: Client = + inject(LANGGRAPH_CLIENT, { optional: true }) + ?? createLangGraphClient(this.config.apiUrl, this.sharedClientOptions ?? undefined); +``` + +The threads adapter has no per-call layer, so its precedence is: shared token → +SDK default (or a fully injected `LANGGRAPH_CLIENT` bypasses both). + +### Data flow + +``` +consumer provides LANGGRAPH_CLIENT_OPTIONS at root + ├─ agent() → resolveClientOptions(call, providerCfg, token) → bridge → FetchStreamTransport → createLangGraphClient(apiUrl, opts) → SDK callerOptions + └─ LangGraphThreadsAdapter → createLangGraphClient(apiUrl, token) → SDK callerOptions +``` + +## Example app migration (examples/chat) + +- `app.config.ts`: add + `{ provide: LANGGRAPH_CLIENT_OPTIONS, useFactory: () => e2eClientOptions() }`. + The factory runs at injection time (post-bootstrap) so the + `THREADPLANE_E2E_MAX_RETRIES` localStorage flag is readable; returning + `undefined` is fine (read sites treat absent/undefined as "SDK default"). +- `DemoShell`: revert `provideAgent` to the **static** object form and drop the + per-agent `clientOptions: e2eClientOptions()`. The shared token supplies it + now. This unwinds the factory-form timing workaround added in #677 (net + simplification). +- `error-handling.spec.ts`, `e2e-overrides.ts` (+ its spec): unchanged. The flag + now flows through the root token to both the agent and the threads adapter. + +## Error handling + +No new failure modes. The token is optional everywhere; absence preserves the +SDK default. Providing `undefined` (factory returns `undefined`) behaves +identically to not providing the token. + +## Testing + +- **Unit (lib):** `resolveClientOptions()` precedence — table-driven (call-site + wins over provider over token over none). +- **Unit (lib):** `LangGraphThreadsAdapter` threads the injected token into the + constructed client — `vi.mock` (or spy) `createLangGraphClient` and assert it + is called with `(apiUrl, { maxRetries: N })` when `LANGGRAPH_CLIENT_OPTIONS` is + provided, and with `(apiUrl, undefined)` when it is not. Also assert the + `LANGGRAPH_CLIENT` bypass still wins (createLangGraphClient not called). +- **Unit (example):** existing `e2eClientOptions` spec stays. New specs added + under `src/` MUST `import { describe, it, expect, ... } from 'vitest'` + (tsconfig.app.json type-checks specs with `types: []`). +- **e2e:** existing `error-handling.spec.ts` stays green (agent path). No new + thread-CRUD-abort e2e (out of scope). + +## Gates (lesson applied) + +- `nx run-many -t lint test --projects=langgraph` +- **`nx build examples-chat-angular`** AND `nx test examples-chat-angular` + (the app build type-checks specs — `nx test` alone does not catch a spec that + breaks the app build, which regressed main at #677). +- `npm run generate-api-docs` (the new `LANGGRAPH_CLIENT_OPTIONS` token is public + API). +- Build one example before claiming green. + +## Public API delta + +- New export: `LANGGRAPH_CLIENT_OPTIONS` (InjectionToken). +- Possibly `resolveClientOptions` stays internal (not exported) unless a + consumer needs it — default keep internal. +- `LangGraphClientOptions`, `provideAgent({ clientOptions })` unchanged (#677). From 0732a57197ee4412025b4d905441670593a8df74 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 07:36:39 -0700 Subject: [PATCH 2/7] docs(plan): shared LangGraph client options implementation plan Co-Authored-By: Claude Fable 5 --- ...6-06-18-langgraph-shared-client-options.md | 501 ++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-langgraph-shared-client-options.md diff --git a/docs/superpowers/plans/2026-06-18-langgraph-shared-client-options.md b/docs/superpowers/plans/2026-06-18-langgraph-shared-client-options.md new file mode 100644 index 00000000..e0d0152a --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-langgraph-shared-client-options.md @@ -0,0 +1,501 @@ +# Shared LangGraph Client Options — 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:** Add one app-wide DI token (`LANGGRAPH_CLIENT_OPTIONS`) that both the streaming agent transport and the threads adapter read, so the LangGraph SDK retry budget is configured once. + +**Architecture:** A new `InjectionToken` plus a pure `resolveClientOptions(...layers)` first-defined-wins helper. `agent()` resolves call-site → `provideAgent` → token; `LangGraphThreadsAdapter` reads the token directly (the `LANGGRAPH_CLIENT` bypass stays). `examples/chat` provides the token once at root from its existing `e2eClientOptions()` and reverts `DemoShell` to static `provideAgent`. + +**Tech Stack:** Angular standalone DI, `@langchain/langgraph-sdk` Client (`callerOptions.maxRetries`), Vitest, Nx, Playwright. + +**Branch:** `feat/langgraph-shared-client-options` (already created off main; the design spec is committed there). + +--- + +## File Structure + +- **Create** `libs/langgraph/src/lib/client/client-options.ts` — the `LANGGRAPH_CLIENT_OPTIONS` token + pure `resolveClientOptions()` helper. +- **Create** `libs/langgraph/src/lib/client/client-options.spec.ts` — precedence unit tests. +- **Modify** `libs/langgraph/src/public-api.ts` — export the token. +- **Modify** `libs/langgraph/src/lib/agent.fn.ts` — inject the token, resolve, pass resolved options into the bridge. +- **Modify** `libs/langgraph/src/lib/threads/threads-adapter.ts` — inject the token, pass to `createLangGraphClient`. +- **Modify** `libs/langgraph/src/lib/threads/threads-adapter.spec.ts` — assert the token is threaded. +- **Modify** `examples/chat/angular/src/app/app.config.ts` — provide the token from `e2eClientOptions()`. +- **Modify** `examples/chat/angular/src/app/shell/demo-shell.component.ts` — revert to static `provideAgent`, drop the per-agent `clientOptions` + now-unused import. +- **Regenerate** `apps/website/content/docs/langgraph/api/api-docs.json`. + +`LangGraphClientOptions` already exists (`agent.types.ts`, exported). `createLangGraphClient(apiUrl, clientOptions?)` already accepts options (#677). + +--- + +### Task 1: `client-options.ts` — token + `resolveClientOptions` + +**Files:** +- Create: `libs/langgraph/src/lib/client/client-options.ts` +- Test: `libs/langgraph/src/lib/client/client-options.spec.ts` +- Modify: `libs/langgraph/src/public-api.ts` + +- [ ] **Step 1: Write the failing test** + +Create `libs/langgraph/src/lib/client/client-options.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { resolveClientOptions } from './client-options'; + +describe('resolveClientOptions', () => { + it('returns undefined when every layer is undefined/null', () => { + expect(resolveClientOptions(undefined, null, undefined)).toBeUndefined(); + expect(resolveClientOptions()).toBeUndefined(); + }); + + it('returns the first defined layer (highest precedence first)', () => { + expect(resolveClientOptions({ maxRetries: 0 }, { maxRetries: 4 })).toEqual({ maxRetries: 0 }); + }); + + it('falls through to a later layer when earlier layers are absent', () => { + expect(resolveClientOptions(undefined, undefined, { maxRetries: 2 })).toEqual({ maxRetries: 2 }); + }); + + it('treats only undefined/null as absent (an empty object is a real layer)', () => { + expect(resolveClientOptions({}, { maxRetries: 4 })).toEqual({}); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test langgraph --skip-nx-cache -- client-options` +Expected: FAIL — cannot find module `./client-options` / `resolveClientOptions is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +Create `libs/langgraph/src/lib/client/client-options.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { InjectionToken } from '@angular/core'; +import type { LangGraphClientOptions } from '../agent.types'; + +/** + * App-wide LangGraph SDK client tuning (e.g. `maxRetries`). Provide once at the + * app root; both the agent's default {@link FetchStreamTransport} and the + * {@link LangGraphThreadsAdapter} read it so the retry budget is configured in + * one place. A per-agent `provideAgent({ clientOptions })` overrides it for that + * agent. Absent → the SDK default. + */ +export const LANGGRAPH_CLIENT_OPTIONS = new InjectionToken( + 'LANGGRAPH_CLIENT_OPTIONS', +); + +/** + * First-defined-wins resolution across precedence layers (highest first). + * Whole-object semantics — no per-field merge — so the winning layer is the + * single source for every option. Returns undefined when all layers are absent. + */ +export function resolveClientOptions( + ...layers: Array +): LangGraphClientOptions | undefined { + for (const layer of layers) { + if (layer) return layer; + } + return undefined; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test langgraph --skip-nx-cache -- client-options` +Expected: PASS (4 tests). + +- [ ] **Step 5: Export the token from the public API** + +In `libs/langgraph/src/public-api.ts`, immediately after the existing line +`export { createLangGraphClient, toAbsoluteApiUrl } from './lib/client/create-langgraph-client';` add: + +```ts +export { LANGGRAPH_CLIENT_OPTIONS } from './lib/client/client-options'; +``` + +(Do NOT export `resolveClientOptions` — it stays internal.) + +- [ ] **Step 6: Commit** + +```bash +git add libs/langgraph/src/lib/client/client-options.ts libs/langgraph/src/lib/client/client-options.spec.ts libs/langgraph/src/public-api.ts +git commit -m "feat(langgraph): LANGGRAPH_CLIENT_OPTIONS token + resolveClientOptions helper" +``` + +--- + +### Task 2: Agent read site — resolve token in `agent.fn.ts` + +**Files:** +- Modify: `libs/langgraph/src/lib/agent.fn.ts` + +Context: `agent()` runs in an injection context (it already calls `inject(DestroyRef)` and `inject(AGENT_CONFIG, { optional: true })`). It currently builds the bridge options at the `createStreamManagerBridge({ options: { ...options, apiUrl, transport }, ... })` call. The bridge already forwards `options.clientOptions` to `FetchStreamTransport` (added in #677), so we resolve the effective options here and set `clientOptions` on the bridge options. + +- [ ] **Step 1: Add the import** + +At the top of `libs/langgraph/src/lib/agent.fn.ts`, alongside the other `./internals` / local imports (e.g. near `import { createStreamManagerBridge } from './internals/stream-manager.bridge';`), add: + +```ts +import { LANGGRAPH_CLIENT_OPTIONS, resolveClientOptions } from './client/client-options'; +``` + +- [ ] **Step 2: Inject the token and resolve precedence** + +Find this block (currently around lines 146-153): + +```ts + const destroyRef = inject(DestroyRef); + const globalConfig = inject(AGENT_CONFIG, { optional: true }); + const destroy$ = new Subject(); + destroyRef.onDestroy(() => { destroy$.next(); destroy$.complete(); }); + + // Merge: call-site options take precedence over global provider config + const apiUrl = options.apiUrl ?? globalConfig?.apiUrl ?? ''; + const transport = options.transport ?? globalConfig?.transport; +``` + +Replace it with (adds the token inject + a resolved `clientOptions`): + +```ts + const destroyRef = inject(DestroyRef); + const globalConfig = inject(AGENT_CONFIG, { optional: true }); + const sharedClientOptions = inject(LANGGRAPH_CLIENT_OPTIONS, { optional: true }); + const destroy$ = new Subject(); + destroyRef.onDestroy(() => { destroy$.next(); destroy$.complete(); }); + + // Merge: call-site options take precedence over global provider config + const apiUrl = options.apiUrl ?? globalConfig?.apiUrl ?? ''; + const transport = options.transport ?? globalConfig?.transport; + // clientOptions precedence: agent({...}) call-site → provideAgent config → + // app-wide LANGGRAPH_CLIENT_OPTIONS token → SDK default. + const clientOptions = resolveClientOptions( + options.clientOptions, + globalConfig?.clientOptions, + sharedClientOptions, + ); +``` + +- [ ] **Step 3: Pass the resolved options into the bridge** + +Find the bridge construction (currently around line 293): + +```ts + const manager = createStreamManagerBridge({ + options: { ...options, apiUrl, transport }, + subjects, + threadId$, + destroy$: destroy$.asObservable(), + }); +``` + +Change the `options` line to set the resolved `clientOptions` (it overrides the spread `options.clientOptions`): + +```ts + const manager = createStreamManagerBridge({ + options: { ...options, apiUrl, transport, clientOptions }, + subjects, + threadId$, + destroy$: destroy$.asObservable(), + }); +``` + +- [ ] **Step 4: Verify the lib still type-checks and tests pass** + +Run: `npx nx run-many -t lint test --projects=langgraph --skip-nx-cache` +Expected: PASS (existing agent specs still green; no new failures). If an existing `agent.spec.ts` constructs an agent without providing `LANGGRAPH_CLIENT_OPTIONS`, that is fine — `inject(..., { optional: true })` returns `null`. + +- [ ] **Step 5: Commit** + +```bash +git add libs/langgraph/src/lib/agent.fn.ts +git commit -m "feat(langgraph): agent() resolves LANGGRAPH_CLIENT_OPTIONS (call-site > provider > token)" +``` + +--- + +### Task 3: Threads adapter read site + test + +**Files:** +- Modify: `libs/langgraph/src/lib/threads/threads-adapter.ts` +- Test: `libs/langgraph/src/lib/threads/threads-adapter.spec.ts` + +- [ ] **Step 1: Write the failing test** + +In `libs/langgraph/src/lib/threads/threads-adapter.spec.ts`, add the module mock and a new describe block. At the very top of the file (after the existing imports), add a `vi.mock` of the client factory and import its mocked form: + +```ts +import { createLangGraphClient } from '../client/create-langgraph-client'; +import { LANGGRAPH_CLIENT_OPTIONS } from '../client/client-options'; + +vi.mock('../client/create-langgraph-client', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createLangGraphClient: vi.fn(actual.createLangGraphClient) }; +}); +``` + +Then add this block at the end of the file (inside the top-level `describe` or as a sibling — match the file's existing structure): + +```ts +describe('LangGraphThreadsAdapter client options', () => { + beforeEach(() => { + vi.mocked(createLangGraphClient).mockClear(); + }); + + it('threads LANGGRAPH_CLIENT_OPTIONS into createLangGraphClient', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x' } }, + { provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } }, + ], + }); + TestBed.inject(LangGraphThreadsAdapter); + expect(createLangGraphClient).toHaveBeenCalledWith('http://x', { maxRetries: 0 }); + }); + + it('passes undefined options when the token is absent', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x' } }, + ], + }); + TestBed.inject(LangGraphThreadsAdapter); + expect(createLangGraphClient).toHaveBeenCalledWith('http://x', undefined); + }); + + it('does not construct a client when LANGGRAPH_CLIENT is provided (bypass intact)', () => { + const injected = { threads: {} } as unknown as Client; + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x' } }, + { provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } }, + { provide: LANGGRAPH_CLIENT, useValue: injected }, + ], + }); + TestBed.inject(LangGraphThreadsAdapter); + expect(createLangGraphClient).not.toHaveBeenCalled(); + }); +}); +``` + +Note: `TestBed.resetTestingModule()` between tests is handled by the framework's default; if the file disables auto-reset, add `TestBed.resetTestingModule()` in the `beforeEach`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test langgraph --skip-nx-cache -- threads-adapter` +Expected: FAIL — `createLangGraphClient` called with `('http://x')` not `('http://x', { maxRetries: 0 })` (the adapter doesn't read the token yet); the `undefined` test may already pass. + +- [ ] **Step 3: Implement — inject the token and thread it** + +In `libs/langgraph/src/lib/threads/threads-adapter.ts`, add the import near the existing `createLangGraphClient` import: + +```ts +import { LANGGRAPH_CLIENT_OPTIONS } from '../client/client-options'; +``` + +Then change the client field initializer. Current (around lines 63-65): + +```ts + private readonly config = inject(LANGGRAPH_THREADS_CONFIG); + private readonly client: Client = inject(LANGGRAPH_CLIENT, { optional: true }) + ?? createLangGraphClient(this.config.apiUrl); +``` + +Replace with (declare `sharedClientOptions` BEFORE `client` so field-init order is correct): + +```ts + private readonly config = inject(LANGGRAPH_THREADS_CONFIG); + private readonly sharedClientOptions = inject(LANGGRAPH_CLIENT_OPTIONS, { optional: true }) ?? undefined; + private readonly client: Client = inject(LANGGRAPH_CLIENT, { optional: true }) + ?? createLangGraphClient(this.config.apiUrl, this.sharedClientOptions); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test langgraph --skip-nx-cache -- threads-adapter` +Expected: PASS (all three new cases + existing threads-adapter tests). + +- [ ] **Step 5: Commit** + +```bash +git add libs/langgraph/src/lib/threads/threads-adapter.ts libs/langgraph/src/lib/threads/threads-adapter.spec.ts +git commit -m "feat(langgraph): threads adapter reads LANGGRAPH_CLIENT_OPTIONS" +``` + +--- + +### Task 4: examples/chat migration — root token + revert DemoShell + +**Files:** +- Modify: `examples/chat/angular/src/app/app.config.ts` +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` + +- [ ] **Step 1: Provide the token at app root** + +In `examples/chat/angular/src/app/app.config.ts`: + +1. Change the langgraph import to add the token. Current: + +```ts +import { LANGGRAPH_THREADS_CONFIG } from '@threadplane/langgraph'; +``` + +to: + +```ts +import { LANGGRAPH_THREADS_CONFIG, LANGGRAPH_CLIENT_OPTIONS } from '@threadplane/langgraph'; +``` + +2. Add the helper import (after the `provideChat` import line): + +```ts +import { e2eClientOptions } from './shell/e2e-overrides'; +``` + +3. In the `providers` array, immediately after the `LANGGRAPH_THREADS_CONFIG` provider object, add: + +```ts + // Single source of truth for the SDK client retry budget — both the agent + // transport and the threads adapter read this. Production: e2eClientOptions() + // returns undefined → SDK default. Under e2e: the THREADPLANE_E2E_MAX_RETRIES + // localStorage flag → fail fast. useFactory runs at injection time (post- + // bootstrap), so the flag is readable. + { provide: LANGGRAPH_CLIENT_OPTIONS, useFactory: () => e2eClientOptions() }, +``` + +- [ ] **Step 2: Revert DemoShell to static provideAgent** + +In `examples/chat/angular/src/app/shell/demo-shell.component.ts`: + +1. Remove the now-unused import line: + +```ts +import { e2eClientOptions } from './e2e-overrides'; +``` + +2. Change the `provideAgent` call from the factory form back to a static object, removing the `clientOptions` spread and the factory comment. Current: + +```ts + // Factory form: the config is resolved lazily at injection time (when the + // 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(() => ({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.assistantId, + // Production keeps the SDK's default connect-retry budget. e2e specs that + // force a connection failure set localStorage['THREADPLANE_E2E_MAX_RETRIES'] + // so the error surfaces immediately instead of after the backoff window. + ...(e2eClientOptions() ? { clientOptions: e2eClientOptions() } : {}), + threadId: threadIdState, + onThreadId: (id: string) => { + // The signal→URL effect picks this up and stamps the new id + // into the URL — no persistence write needed any more, URL is + // the source of truth. + threadIdState.set(id); + }, + // Phase 3B: tells SubagentTracker to treat `research` tool calls as + // subagent dispatches and to materialize agent.subagents() from the + // resulting tools:-namespaced stream events. + subagentToolNames: ['research'], + telemetry: (event) => telemetrySink?.(event), + })), +``` + +Replace with: + +```ts + provideAgent({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.assistantId, + threadId: threadIdState, + onThreadId: (id: string) => { + // The signal→URL effect picks this up and stamps the new id + // into the URL — no persistence write needed any more, URL is + // the source of truth. + threadIdState.set(id); + }, + // Phase 3B: tells SubagentTracker to treat `research` tool calls as + // subagent dispatches and to materialize agent.subagents() from the + // resulting tools:-namespaced stream events. + subagentToolNames: ['research'], + telemetry: (event) => telemetrySink?.(event), + }), +``` + +- [ ] **Step 3: Build the app (CRITICAL — catches spec/app-build breakage)** + +Run: `npx nx build examples-chat-angular --skip-nx-cache` +Expected: `Successfully ran target build` (a bundle-budget WARNING is acceptable; an ERROR is not). This gate is mandatory — `nx test` alone does NOT compile the app and would miss a build break. + +- [ ] **Step 4: Run the example unit tests** + +Run: `npx nx test examples-chat-angular --skip-nx-cache` +Expected: PASS (demo-shell spec + e2e-overrides spec unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/app.config.ts examples/chat/angular/src/app/shell/demo-shell.component.ts +git commit -m "refactor(examples/chat): provide LANGGRAPH_CLIENT_OPTIONS at root; revert DemoShell to static provideAgent" +``` + +--- + +### Task 5: Regenerate api-docs + full gates + +**Files:** +- Modify: `apps/website/content/docs/langgraph/api/api-docs.json` (generated) + +- [ ] **Step 1: Regenerate API docs** + +Run: `npm run generate-api-docs` +Expected: exit 0; `git status --porcelain apps/website/content/docs/langgraph/api/api-docs.json` shows it modified (the new `LANGGRAPH_CLIENT_OPTIONS` token). Confirm no `copilotkit` string appears anywhere in `git diff` (`git diff | grep -i copilotkit` must be empty). + +- [ ] **Step 2: Run the full local gates** + +Run each; all must pass: +```bash +npx nx run-many -t lint test --projects=langgraph --skip-nx-cache +npx nx build examples-chat-angular --skip-nx-cache +npx nx test examples-chat-angular --skip-nx-cache +``` +Expected: all green (bundle-budget WARNING acceptable). + +- [ ] **Step 3: Run the examples/chat e2e (agent path still green)** + +First free the dev-server ports (now reaped by the new teardown, but be safe): +```bash +lsof -ti :4200 :2024 | xargs -r kill -9 2>/dev/null || true +``` +Run: `npx nx e2e examples-chat-angular --skip-nx-cache -- --grep "error handling"` +Expected: 1 passed (the failed stream still surfaces the alert fast — now via the shared token instead of per-agent clientOptions). + +- [ ] **Step 4: Commit the generated docs** + +```bash +git add apps/website/content/docs/langgraph/api/api-docs.json +git commit -m "docs(langgraph): regenerate api-docs for LANGGRAPH_CLIENT_OPTIONS" +``` + +- [ ] **Step 5: Push + open PR** + +```bash +git push -u origin feat/langgraph-shared-client-options +gh pr create --title "feat(langgraph): single-source LANGGRAPH_CLIENT_OPTIONS for retry budget" --body "..." +``` + +PR body: summarize the token + precedence, the threads-adapter completion, and the examples/chat migration; link the design spec. + +--- + +## Notes for the implementer + +- `inject(TOKEN, { optional: true })` returns `null` when unprovided — `resolveClientOptions` treats `null` as absent, and the threads adapter coerces with `?? undefined` so `createLangGraphClient(apiUrl, undefined)` keeps the SDK default. +- Do NOT add a `clientOptions` field to `LangGraphThreadsConfig` — the token is the single source on the threads side (avoids a second knob). +- Do NOT change the SDK default or refactor `apiUrl` wiring (YAGNI per the spec). +- When adding any new `*.spec.ts` under `examples/chat/angular/src/`, it MUST `import { describe, it, expect, ... } from 'vitest'` — `tsconfig.app.json` type-checks specs with `types: []`, so ambient globals break the app build. From 97fcd5ebd5597f824457f0f1b1f6e3e7aa7cf4f7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 07:39:03 -0700 Subject: [PATCH 3/7] feat(langgraph): LANGGRAPH_CLIENT_OPTIONS token + resolveClientOptions helper Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/client/client-options.spec.ts | 22 +++++++++++++++ .../src/lib/client/client-options.ts | 28 +++++++++++++++++++ libs/langgraph/src/public-api.ts | 1 + 3 files changed, 51 insertions(+) create mode 100644 libs/langgraph/src/lib/client/client-options.spec.ts create mode 100644 libs/langgraph/src/lib/client/client-options.ts diff --git a/libs/langgraph/src/lib/client/client-options.spec.ts b/libs/langgraph/src/lib/client/client-options.spec.ts new file mode 100644 index 00000000..d01eb9d3 --- /dev/null +++ b/libs/langgraph/src/lib/client/client-options.spec.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { resolveClientOptions } from './client-options'; + +describe('resolveClientOptions', () => { + it('returns undefined when every layer is undefined/null', () => { + expect(resolveClientOptions(undefined, null, undefined)).toBeUndefined(); + expect(resolveClientOptions()).toBeUndefined(); + }); + + it('returns the first defined layer (highest precedence first)', () => { + expect(resolveClientOptions({ maxRetries: 0 }, { maxRetries: 4 })).toEqual({ maxRetries: 0 }); + }); + + it('falls through to a later layer when earlier layers are absent', () => { + expect(resolveClientOptions(undefined, undefined, { maxRetries: 2 })).toEqual({ maxRetries: 2 }); + }); + + it('treats only undefined/null as absent (an empty object is a real layer)', () => { + expect(resolveClientOptions({}, { maxRetries: 4 })).toEqual({}); + }); +}); diff --git a/libs/langgraph/src/lib/client/client-options.ts b/libs/langgraph/src/lib/client/client-options.ts new file mode 100644 index 00000000..6b9ed60c --- /dev/null +++ b/libs/langgraph/src/lib/client/client-options.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +import { InjectionToken } from '@angular/core'; +import type { LangGraphClientOptions } from '../agent.types'; + +/** + * App-wide LangGraph SDK client tuning (e.g. `maxRetries`). Provide once at the + * app root; both the agent's default {@link FetchStreamTransport} and the + * {@link LangGraphThreadsAdapter} read it so the retry budget is configured in + * one place. A per-agent `provideAgent({ clientOptions })` overrides it for that + * agent. Absent → the SDK default. + */ +export const LANGGRAPH_CLIENT_OPTIONS = new InjectionToken( + 'LANGGRAPH_CLIENT_OPTIONS', +); + +/** + * First-defined-wins resolution across precedence layers (highest first). + * Whole-object semantics — no per-field merge — so the winning layer is the + * single source for every option. Returns undefined when all layers are absent. + */ +export function resolveClientOptions( + ...layers: Array +): LangGraphClientOptions | undefined { + for (const layer of layers) { + if (layer) return layer; + } + return undefined; +} diff --git a/libs/langgraph/src/public-api.ts b/libs/langgraph/src/public-api.ts index c2bd9179..36bf9909 100644 --- a/libs/langgraph/src/public-api.ts +++ b/libs/langgraph/src/public-api.ts @@ -55,6 +55,7 @@ export { extractCitations } from './lib/internals/extract-citations'; // SDK Client helper — handles the SDK's absolute-URL requirement so // `/api`-style relative paths work in browser contexts. export { createLangGraphClient, toAbsoluteApiUrl } from './lib/client/create-langgraph-client'; +export { LANGGRAPH_CLIENT_OPTIONS } from './lib/client/client-options'; // SDK-backed thread store — drop-in replacement for the // hand-rolled ThreadsService that consumers used to duplicate. From 64fb96b6302b2c90629c2dd589faa1f335077297 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 07:41:18 -0700 Subject: [PATCH 4/7] feat(langgraph): agent() resolves LANGGRAPH_CLIENT_OPTIONS (call-site > provider > token) Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/langgraph/src/lib/agent.fn.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 26a3d8c6..92c3bf07 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -61,6 +61,7 @@ import { import type { ThreadState, ToolProgress } from '@langchain/langgraph-sdk'; import type { MessageMetadata } from '@langchain/langgraph-sdk/ui'; import { createStreamManagerBridge } from './internals/stream-manager.bridge'; +import { LANGGRAPH_CLIENT_OPTIONS, resolveClientOptions } from './client/client-options'; import { buildBranchTree } from './internals/branch-tree'; import { extractCitations } from './internals/extract-citations'; import { createClientToolsCapability, mergeClientTools } from './client-tools'; @@ -145,12 +146,20 @@ export function agent< // Injection context required const destroyRef = inject(DestroyRef); const globalConfig = inject(AGENT_CONFIG, { optional: true }); + const sharedClientOptions = inject(LANGGRAPH_CLIENT_OPTIONS, { optional: true }); const destroy$ = new Subject(); destroyRef.onDestroy(() => { destroy$.next(); destroy$.complete(); }); // Merge: call-site options take precedence over global provider config const apiUrl = options.apiUrl ?? globalConfig?.apiUrl ?? ''; const transport = options.transport ?? globalConfig?.transport; + // clientOptions precedence: agent({...}) call-site → provideAgent config → + // app-wide LANGGRAPH_CLIENT_OPTIONS token → SDK default. + const clientOptions = resolveClientOptions( + options.clientOptions, + globalConfig?.clientOptions, + sharedClientOptions, + ); const init = (options.initialValues ?? {}) as T; @@ -291,7 +300,7 @@ export function agent< }); const manager = createStreamManagerBridge({ - options: { ...options, apiUrl, transport }, + options: { ...options, apiUrl, transport, clientOptions }, subjects, threadId$, destroy$: destroy$.asObservable(), From f492e181e42d9f807cc5b63f3ff3b84a26bd33cc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 07:44:12 -0700 Subject: [PATCH 5/7] feat(langgraph): threads adapter reads LANGGRAPH_CLIENT_OPTIONS Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/threads/threads-adapter.spec.ts | 48 +++++++++++++++++++ .../src/lib/threads/threads-adapter.ts | 4 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts index 94182906..184c4cdf 100644 --- a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts +++ b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts @@ -7,6 +7,13 @@ import { LANGGRAPH_CLIENT, } from './threads-adapter'; import type { Client } from '@langchain/langgraph-sdk'; +import { createLangGraphClient } from '../client/create-langgraph-client'; +import { LANGGRAPH_CLIENT_OPTIONS } from '../client/client-options'; + +vi.mock('../client/create-langgraph-client', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createLangGraphClient: vi.fn(actual.createLangGraphClient) }; +}); function mockClient(searchReturn: unknown[] = []): { client: Client; @@ -141,3 +148,44 @@ describe('LangGraphThreadsAdapter', () => { errSpy.mockRestore(); }); }); + +describe('LangGraphThreadsAdapter client options', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + vi.mocked(createLangGraphClient).mockClear(); + }); + + it('threads LANGGRAPH_CLIENT_OPTIONS into createLangGraphClient', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x' } }, + { provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } }, + ], + }); + TestBed.inject(LangGraphThreadsAdapter); + expect(createLangGraphClient).toHaveBeenCalledWith('http://x', { maxRetries: 0 }); + }); + + it('passes undefined options when the token is absent', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x' } }, + ], + }); + TestBed.inject(LangGraphThreadsAdapter); + expect(createLangGraphClient).toHaveBeenCalledWith('http://x', undefined); + }); + + it('does not construct a client when LANGGRAPH_CLIENT is provided (bypass intact)', () => { + const injected = { threads: {} } as unknown as Client; + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x' } }, + { provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } }, + { provide: LANGGRAPH_CLIENT, useValue: injected }, + ], + }); + TestBed.inject(LangGraphThreadsAdapter); + expect(createLangGraphClient).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/langgraph/src/lib/threads/threads-adapter.ts b/libs/langgraph/src/lib/threads/threads-adapter.ts index fdb6968a..7d9e09e4 100644 --- a/libs/langgraph/src/lib/threads/threads-adapter.ts +++ b/libs/langgraph/src/lib/threads/threads-adapter.ts @@ -3,6 +3,7 @@ import { Injectable, InjectionToken, inject, signal, type Signal, type WritableS import type { Client, Thread as SdkThread } from '@langchain/langgraph-sdk'; import type { Thread } from '@threadplane/chat'; import { createLangGraphClient } from '../client/create-langgraph-client'; +import { LANGGRAPH_CLIENT_OPTIONS } from '../client/client-options'; /** * Configuration consumed by {@link LangGraphThreadsAdapter}. Provide @@ -61,8 +62,9 @@ export const LANGGRAPH_CLIENT = new InjectionToken('LANGGRAPH_CLIENT'); @Injectable({ providedIn: 'root' }) export class LangGraphThreadsAdapter { private readonly config = inject(LANGGRAPH_THREADS_CONFIG); + private readonly sharedClientOptions = inject(LANGGRAPH_CLIENT_OPTIONS, { optional: true }) ?? undefined; private readonly client: Client = inject(LANGGRAPH_CLIENT, { optional: true }) - ?? createLangGraphClient(this.config.apiUrl); + ?? createLangGraphClient(this.config.apiUrl, this.sharedClientOptions); private readonly fallback: string = this.config.titleFallback ?? 'Untitled'; From 42894e43a0647605b0f01ec5f68bf35a773f8d8d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 07:47:11 -0700 Subject: [PATCH 6/7] refactor(examples/chat): provide LANGGRAPH_CLIENT_OPTIONS at root; revert DemoShell to static provideAgent Co-Authored-By: Claude Sonnet 4.6 --- examples/chat/angular/src/app/app.config.ts | 9 ++++++++- .../angular/src/app/shell/demo-shell.component.ts | 13 ++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/examples/chat/angular/src/app/app.config.ts b/examples/chat/angular/src/app/app.config.ts index 412d69e4..d75ac3c3 100644 --- a/examples/chat/angular/src/app/app.config.ts +++ b/examples/chat/angular/src/app/app.config.ts @@ -2,8 +2,9 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; -import { LANGGRAPH_THREADS_CONFIG } from '@threadplane/langgraph'; +import { LANGGRAPH_THREADS_CONFIG, LANGGRAPH_CLIENT_OPTIONS } from '@threadplane/langgraph'; import { provideChat } from '@threadplane/chat'; +import { e2eClientOptions } from './shell/e2e-overrides'; import { routes } from './app.routes'; import { environment } from '../environments/environment'; @@ -18,6 +19,12 @@ export const appConfig: ApplicationConfig = { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: environment.langGraphApiUrl }, }, + // Single source of truth for the SDK client retry budget — both the agent + // transport and the threads adapter read this. Production: e2eClientOptions() + // returns undefined → SDK default. Under e2e: the THREADPLANE_E2E_MAX_RETRIES + // localStorage flag → fail fast. useFactory runs at injection time (post- + // bootstrap), so the flag is readable. + { provide: LANGGRAPH_CLIENT_OPTIONS, useFactory: () => e2eClientOptions() }, // Optional license token, populated from environment.license. When // unset (the default in main), @threadplane/chat runs in advisory mode and // logs a console.warn once. A smoke-test session can drop a real 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..01950a7f 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -33,7 +33,6 @@ import { import { PalettePersistence } from './palette-persistence.service'; import { ProjectsService } from './projects.service'; import { DEMO_AGENT } from './shell-tokens'; -import { e2eClientOptions } from './e2e-overrides'; import { createCanonicalDemoRuntimeTelemetrySink } from './runtime-telemetry'; import { environment } from '../../environments/environment'; @@ -86,17 +85,9 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } { // model selection — all per-instance. The telemetry sink delegates to the // shell-built sink (populated in the constructor) since the real sink needs // the injected telemetry service + live model() read. - // Factory form: the config is resolved lazily at injection time (when the - // 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({ apiUrl: environment.langGraphApiUrl, assistantId: environment.assistantId, - // Production keeps the SDK's default connect-retry budget. e2e specs that - // force a connection failure set localStorage['THREADPLANE_E2E_MAX_RETRIES'] - // so the error surfaces immediately instead of after the backoff window. - ...(e2eClientOptions() ? { clientOptions: e2eClientOptions() } : {}), threadId: threadIdState, onThreadId: (id: string) => { // The signal→URL effect picks this up and stamps the new id @@ -109,7 +100,7 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } { // resulting tools:-namespaced stream events. subagentToolNames: ['research'], telemetry: (event) => telemetrySink?.(event), - })), + }), { provide: DEMO_AGENT, useFactory: () => inject(DemoShell).agent }, ], }) From c7accf160a341e2ebbeb0e337388c41122b4cb06 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 07:58:48 -0700 Subject: [PATCH 7/7] test(langgraph): cover agent-side LANGGRAPH_CLIENT_OPTIONS resolution + override ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a describe block in agent.fn.spec.ts that exercises the FetchStreamTransport / createLangGraphClient path (no MockTransport) with four precedence cases: token-only, call-site wins, none provided, and AGENT_CONFIG middle layer. Also extends the LANGGRAPH_CLIENT_OPTIONS JSDoc to mention the full call-site → provideAgent → token precedence chain. Co-Authored-By: Claude Sonnet 4.6 --- libs/langgraph/src/lib/agent.fn.spec.ts | 114 +++++++++++++++++- .../src/lib/client/client-options.ts | 5 +- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/libs/langgraph/src/lib/agent.fn.spec.ts b/libs/langgraph/src/lib/agent.fn.spec.ts index 958503d8..89651759 100644 --- a/libs/langgraph/src/lib/agent.fn.spec.ts +++ b/libs/langgraph/src/lib/agent.fn.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { signal } from '@angular/core'; import type { AIMessage as CoreAIMessage } from '@langchain/core/messages'; @@ -6,6 +6,14 @@ import { agent } from './agent.fn'; import { MockAgentTransport } from './transport/mock-stream.transport'; import type { StreamEvent } from './agent.types'; import type { ThreadState } from '@langchain/langgraph-sdk'; +import { createLangGraphClient } from './client/create-langgraph-client'; +import { LANGGRAPH_CLIENT_OPTIONS } from './client/client-options'; +import { AGENT_CONFIG } from './agent.provider'; + +vi.mock('./client/create-langgraph-client', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createLangGraphClient: vi.fn(actual.createLangGraphClient) }; +}); function withInjectionContext(fn: () => T): T { let result!: T; @@ -896,6 +904,110 @@ describe('agent', () => { }); }); +describe('agent — LANGGRAPH_CLIENT_OPTIONS resolution (no mock transport)', () => { + // Spy on createLangGraphClient so we can assert which clientOptions reach + // the FetchStreamTransport constructor, without making real network calls. + // The mock returns a minimal stub object that satisfies all downstream + // accesses (threads.create, runs.stream, etc. are never called in these + // construction-only tests). + const mockClientStub = { + threads: { + create: vi.fn().mockResolvedValue({ thread_id: 'stub-thread' }), + getState: vi.fn().mockResolvedValue({ values: {}, next: [] }), + patchState: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + }, + runs: { + stream: vi.fn().mockReturnValue((async function* () {})()), + }, + }; + + const createLangGraphClientMock = createLangGraphClient as ReturnType; + + beforeEach(() => { + TestBed.resetTestingModule(); + createLangGraphClientMock.mockClear(); + createLangGraphClientMock.mockReturnValue(mockClientStub); + }); + + it('case 1 — token only: passes token clientOptions to createLangGraphClient', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } }, + ], + }); + + TestBed.runInInjectionContext(() => { + agent({ apiUrl: 'http://localhost:2024', assistantId: 'a' }); + }); + + expect(createLangGraphClientMock).toHaveBeenCalledWith( + 'http://localhost:2024', + { maxRetries: 0 }, + ); + }); + + it('case 2 — call-site wins: call-site clientOptions override the token', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } }, + ], + }); + + TestBed.runInInjectionContext(() => { + agent({ + apiUrl: 'http://localhost:2024', + assistantId: 'a', + clientOptions: { maxRetries: 7 }, + }); + }); + + expect(createLangGraphClientMock).toHaveBeenCalledWith( + 'http://localhost:2024', + { maxRetries: 7 }, + ); + }); + + it('case 3 — none provided: passes undefined clientOptions to createLangGraphClient', () => { + TestBed.configureTestingModule({}); + + TestBed.runInInjectionContext(() => { + agent({ apiUrl: 'http://localhost:2024', assistantId: 'a' }); + }); + + expect(createLangGraphClientMock).toHaveBeenCalledWith( + 'http://localhost:2024', + undefined, + ); + }); + + it('case 4 — AGENT_CONFIG middle layer: provideAgent clientOptions override the token', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_CLIENT_OPTIONS, useValue: { maxRetries: 0 } }, + { + provide: AGENT_CONFIG, + useValue: { + assistantId: 'a', + apiUrl: 'http://localhost:2024', + clientOptions: { maxRetries: 3 }, + }, + }, + ], + }); + + TestBed.runInInjectionContext(() => { + // No call-site clientOptions — AGENT_CONFIG.clientOptions is the winner. + agent({ apiUrl: 'http://localhost:2024', assistantId: 'a' }); + }); + + expect(createLangGraphClientMock).toHaveBeenCalledWith( + 'http://localhost:2024', + { maxRetries: 3 }, + ); + }); +}); + import { computeMessageCheckpoints } from './agent.fn'; describe('computeMessageCheckpoints', () => { diff --git a/libs/langgraph/src/lib/client/client-options.ts b/libs/langgraph/src/lib/client/client-options.ts index 6b9ed60c..dc378247 100644 --- a/libs/langgraph/src/lib/client/client-options.ts +++ b/libs/langgraph/src/lib/client/client-options.ts @@ -6,8 +6,9 @@ import type { LangGraphClientOptions } from '../agent.types'; * App-wide LangGraph SDK client tuning (e.g. `maxRetries`). Provide once at the * app root; both the agent's default {@link FetchStreamTransport} and the * {@link LangGraphThreadsAdapter} read it so the retry budget is configured in - * one place. A per-agent `provideAgent({ clientOptions })` overrides it for that - * agent. Absent → the SDK default. + * one place. A call-site `agent({ clientOptions })` or per-agent + * `provideAgent({ clientOptions })` overrides it for that agent. + * Absent → the SDK default. */ export const LANGGRAPH_CLIENT_OPTIONS = new InjectionToken( 'LANGGRAPH_CLIENT_OPTIONS',