From 171fab24c7fdd4ab5767da4b3de1a30d75088e8c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 08:09:36 -0700 Subject: [PATCH] refactor(threads): converge on metadata.title; drop titleMetadataKey config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 2026-05-19-llm-generated-labels-design.md proposed metadata.thread_title for the new cockpit-cap title nodes, but the canonical demo's _maybe_write_thread_title (which predates the spec) writes metadata.title. PR #488's LangGraphThreadsAdapter bridged the two via a per-consumer titleMetadataKey config knob — friction every consumer had to remember. Converge on metadata.title across everything: - cockpit/chat/threads/python + cockpit/chat/a2ui/python graphs now write metadata.title (renamed read+write sites, updated docstrings) - LangGraphThreadsAdapter reads metadata.title unconditionally; the titleMetadataKey field is removed from LangGraphThreadsConfig - Both consumers drop the per-cap titleMetadataKey override from app.config.ts - threads-adapter.spec.ts simplified (one fewer test parameter) - Spec gets a 2026-05-21 addendum documenting the convergence No behavior change for the demo (already on metadata.title). The two cockpit caps now share the demo's convention; existing prod threads written with the old key would lose their title, but the prod backlog was cleared separately and the cockpit caps weren't in prod. Co-Authored-By: Claude Opus 4.7 (1M context) --- cockpit/chat/a2ui/python/src/graph.py | 11 ++++---- .../threads/angular/src/app/app.config.ts | 9 +++---- .../angular/src/app/threads.component.ts | 11 ++++---- cockpit/chat/threads/python/src/graph.py | 18 ++++++++----- .../2026-05-19-llm-generated-labels-design.md | 18 +++++++++++++ examples/chat/angular/src/app/app.config.ts | 9 ++----- .../src/lib/threads/threads-adapter.spec.ts | 25 ++++++------------- .../src/lib/threads/threads-adapter.ts | 18 ++++++------- 8 files changed, 63 insertions(+), 56 deletions(-) diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index cd974eda..95916695 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -774,10 +774,11 @@ async def generate_title(state: MessagesState, config) -> dict: intent into 3-5 words and persist to LangGraph thread metadata so the sidenav shows something meaningful instead of a UUID slice. - Idempotent — skips when metadata.thread_title already exists. Errors - are swallowed (title is a UX nicety, never a blocker). Runs after the + Idempotent — skips when metadata.title already exists. Errors are + swallowed (title is a UX nicety, never a blocker). Runs after the user-visible terminal node so it never blocks the response. See spec - 2026-05-19-llm-generated-labels-design.md. + 2026-05-19-llm-generated-labels-design.md (originally `thread_title`, + converged to `title` for parity with the canonical demo + adapter). """ thread_id = (config.get("configurable") or {}).get("thread_id") if not thread_id: @@ -790,7 +791,7 @@ async def generate_title(state: MessagesState, config) -> dict: try: client = get_client(url=sdk_url) thread = await client.threads.get(thread_id) - if (thread.get("metadata") or {}).get("thread_title"): + if (thread.get("metadata") or {}).get("title"): return {} first_user = next( (m for m in state["messages"] if getattr(m, "type", None) == "human"), @@ -808,7 +809,7 @@ async def generate_title(state: MessagesState, config) -> dict: ]) title = (response.content or "").strip().strip('"').strip("'")[:80] if title: - await client.threads.update(thread_id, metadata={"thread_title": title}) + await client.threads.update(thread_id, metadata={"title": title}) except Exception as err: # noqa: BLE001 — title is a UX nicety; never block _logger.warning("Thread title generation failed: %s", err) return {} diff --git a/cockpit/chat/threads/angular/src/app/app.config.ts b/cockpit/chat/threads/angular/src/app/app.config.ts index 6995bdf9..db8837ee 100644 --- a/cockpit/chat/threads/angular/src/app/app.config.ts +++ b/cockpit/chat/threads/angular/src/app/app.config.ts @@ -8,14 +8,11 @@ export const appConfig: ApplicationConfig = { providers: [ provideAgent({ apiUrl: environment.langGraphApiUrl }), provideChat({}), - // c-threads' Python graph writes the LLM-generated title to - // metadata.thread_title (per spec 2026-05-19-llm-generated-labels-design). + // The adapter expects metadata.title; the cap's generate_title + // graph node writes there. No per-cap key override needed. { provide: LANGGRAPH_THREADS_CONFIG, - useValue: { - apiUrl: environment.langGraphApiUrl, - titleMetadataKey: 'thread_title', - }, + useValue: { apiUrl: environment.langGraphApiUrl }, }, ], }; diff --git a/cockpit/chat/threads/angular/src/app/threads.component.ts b/cockpit/chat/threads/angular/src/app/threads.component.ts index 0a57bec8..47f9ccb2 100644 --- a/cockpit/chat/threads/angular/src/app/threads.component.ts +++ b/cockpit/chat/threads/angular/src/app/threads.component.ts @@ -13,10 +13,11 @@ import { environment } from '../environments/environment'; * ThreadsComponent demonstrates multi-thread conversation management * backed by the real LangGraph SDK. Consumes the shared * LangGraphThreadsAdapter from `@ngaf/langgraph` — same service the - * canonical demo uses — configured for the `metadata.thread_title` - * key that this cap's `generate_title` graph node writes (spec - * 2026-05-19-llm-generated-labels-design). See app.config.ts for the - * LANGGRAPH_THREADS_CONFIG provider. + * canonical demo uses. Reads `metadata.title` written by this cap's + * `generate_title` graph node (spec + * 2026-05-19-llm-generated-labels-design.md, converged on `title` + * after the original `thread_title` choice). See app.config.ts for + * the LANGGRAPH_THREADS_CONFIG provider. */ @Component({ selector: 'app-threads', @@ -86,7 +87,7 @@ export class ThreadsComponent { void this.threadsSvc.refresh(); // Re-fetch when an agent run completes. The graph's generate_title - // node writes metadata.thread_title on the first turn; refreshing + // node writes metadata.title on the first turn; refreshing // on the running→idle transition surfaces it in the sidenav // without a manual reload. refreshOnRunEnd(this.agent, () => this.threadsSvc.refresh()); diff --git a/cockpit/chat/threads/python/src/graph.py b/cockpit/chat/threads/python/src/graph.py index 52a109f0..af611056 100644 --- a/cockpit/chat/threads/python/src/graph.py +++ b/cockpit/chat/threads/python/src/graph.py @@ -4,8 +4,9 @@ Conversational agent with inline thread-title generation. Each new thread gets an LLM-generated 3-5 word title written to LangGraph thread metadata on the first turn (idempotent — subsequent turns skip -the write). The chat-threads frontend reads `metadata.thread_title` -from `client.threads.search()` and displays it in the sidenav. +the write). The chat-threads frontend reads `metadata.title` from +`client.threads.search()` via LangGraphThreadsAdapter and displays +it in the sidenav. Pattern D from spec 2026-05-19-llm-generated-labels-design.md: the generate_title node lives inline in this file (not extracted to a @@ -37,9 +38,14 @@ async def generate_title(state: MessagesState, config) -> dict: intent into 3-5 words and persist to LangGraph thread metadata so the sidenav shows something meaningful instead of a UUID slice. - Idempotent — skips when metadata.thread_title already exists. Errors - are swallowed (title is a UX nicety, never a blocker). Runs after the + Idempotent — skips when metadata.title already exists. Errors are + swallowed (title is a UX nicety, never a blocker). Runs after the user-visible turn so it never blocks the response. + + Writes to `metadata.title` (matches the legacy examples/chat + convention and the LangGraphThreadsAdapter default). Spec + 2026-05-19 originally chose `thread_title`; we converged on + `title` to drop the per-cap `titleMetadataKey` config knob. """ thread_id = (config.get("configurable") or {}).get("thread_id") if not thread_id: @@ -53,7 +59,7 @@ async def generate_title(state: MessagesState, config) -> dict: try: client = get_client(url=sdk_url) thread = await client.threads.get(thread_id) - if (thread.get("metadata") or {}).get("thread_title"): + if (thread.get("metadata") or {}).get("title"): return {} first_user = next( (m for m in state["messages"] if getattr(m, "type", None) == "human"), @@ -71,7 +77,7 @@ async def generate_title(state: MessagesState, config) -> dict: ]) title = (response.content or "").strip().strip('"').strip("'")[:80] if title: - await client.threads.update(thread_id, metadata={"thread_title": title}) + await client.threads.update(thread_id, metadata={"title": title}) except Exception as e: # noqa: BLE001 — title is a UX nicety; never block # Don't break the run, but DO log. A bare pass has hidden a prod # bug in the sibling examples/chat graph where the title write was diff --git a/docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md b/docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md index dea66f74..3a32aaab 100644 --- a/docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md +++ b/docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md @@ -320,3 +320,21 @@ export function a2uiActionLabel(content: string): string | null { - `_builder.add_edge("respond", "generate_title")` — both are node names registered earlier in the file. Consistent. **Anti-pattern check:** zero hardcoded label tables. Zero topology magic. Zero cross-cap python imports. Each cap stays self-contained. + +--- + +## Addendum 2026-05-21 — converged on `metadata.title` + +This spec proposed `metadata.thread_title` for the new cockpit-cap title nodes (c-threads, c-a2ui). After landing #481, #488, #491, #492, #493 the per-cap key created friction: + +- `LangGraphThreadsAdapter` carried a `titleMetadataKey` config knob to bridge the two conventions +- Each consumer had to remember which spelling its backend used +- The canonical demo (`examples/chat/python`) writes `metadata.title` and predates this spec + +Resolved by converging on `metadata.title` across all consumers: + +- `cockpit/chat/threads/python` + `cockpit/chat/a2ui/python` graphs now write `metadata.title` +- `LangGraphThreadsAdapter` reads `metadata.title` unconditionally; the `titleMetadataKey` config knob is gone +- Pre-existing prod threads written with the old `thread_title` spelling would lose their title; the existing prod backlog was cleared separately (see /tmp/delete-prod-threads.sh) + +Pattern D (inline node per cap, no shared helper) stays intact — only the metadata key name changes. diff --git a/examples/chat/angular/src/app/app.config.ts b/examples/chat/angular/src/app/app.config.ts index 88080f77..b309c2de 100644 --- a/examples/chat/angular/src/app/app.config.ts +++ b/examples/chat/angular/src/app/app.config.ts @@ -12,15 +12,10 @@ export const appConfig: ApplicationConfig = { provideZonelessChangeDetection(), provideRouter(routes, withComponentInputBinding()), provideNgafTelemetry(environment.telemetry), - // Configure the shared LangGraphThreadsAdapter. The canonical - // demo's Python graph writes the title to `metadata.title` (the - // legacy spelling — c-threads writes `metadata.thread_title`). + // Configure the shared LangGraphThreadsAdapter. { provide: LANGGRAPH_THREADS_CONFIG, - useValue: { - apiUrl: environment.langGraphApiUrl, - titleMetadataKey: 'title', - }, + useValue: { apiUrl: environment.langGraphApiUrl }, }, ], }; diff --git a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts index 232ac3ce..94182906 100644 --- a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts +++ b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts @@ -27,10 +27,10 @@ function mockClient(searchReturn: unknown[] = []): { }; } -function configure(client: Client, titleKey = 'thread_title'): LangGraphThreadsAdapter { +function configure(client: Client): LangGraphThreadsAdapter { TestBed.configureTestingModule({ providers: [ - { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x', titleMetadataKey: titleKey } }, + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x' } }, { provide: LANGGRAPH_CLIENT, useValue: client }, ], }); @@ -40,12 +40,12 @@ function configure(client: Client, titleKey = 'thread_title'): LangGraphThreadsA describe('LangGraphThreadsAdapter', () => { beforeEach(() => TestBed.resetTestingModule()); - it('maps SDK threads through the configured title metadata key', async () => { + it('maps SDK threads via metadata.title', async () => { const { client } = mockClient([ { thread_id: 't1', updated_at: '2026-05-20T00:00:00Z', - metadata: { thread_title: 'Capital of Japan' }, + metadata: { title: 'Capital of Japan' }, }, ]); const svc = configure(client); @@ -55,15 +55,6 @@ describe('LangGraphThreadsAdapter', () => { ]); }); - it('honours an alternate title key (demo writes metadata.title)', async () => { - const { client } = mockClient([ - { thread_id: 't1', metadata: { title: 'Hello' } }, - ]); - const svc = configure(client, 'title'); - await svc.refresh(); - expect(svc.threads()[0].title).toBe('Hello'); - }); - it('falls back to "Untitled" when title metadata is missing', async () => { const { client } = mockClient([{ thread_id: 't1', metadata: {} }]); const svc = configure(client); @@ -93,11 +84,11 @@ describe('LangGraphThreadsAdapter', () => { expect(svc.threads().map(t => t.id)).toEqual(['p1', 'p2', 'unp']); }); - it('rename() writes the configured title key', async () => { + it('rename() writes metadata.title', async () => { const m = mockClient(); - const svc = configure(m.client, 'thread_title'); + const svc = configure(m.client); await svc.rename('t1', 'New title'); - expect(m.update).toHaveBeenCalledWith('t1', { metadata: { thread_title: 'New title' } }); + expect(m.update).toHaveBeenCalledWith('t1', { metadata: { title: 'New title' } }); }); it('getThread() returns a mapped Thread when the SDK resolves', async () => { @@ -105,7 +96,7 @@ describe('LangGraphThreadsAdapter', () => { m.get.mockResolvedValue({ thread_id: 'tx', updated_at: '2026-05-20T00:00:00Z', - metadata: { thread_title: 'hello' }, + metadata: { title: 'hello' }, }); const svc = configure(m.client); const result = await svc.getThread('tx'); diff --git a/libs/langgraph/src/lib/threads/threads-adapter.ts b/libs/langgraph/src/lib/threads/threads-adapter.ts index aad8321a..79212999 100644 --- a/libs/langgraph/src/lib/threads/threads-adapter.ts +++ b/libs/langgraph/src/lib/threads/threads-adapter.ts @@ -12,21 +12,20 @@ import { createLangGraphClient } from '../client/create-langgraph-client'; * providers: [ * { provide: LANGGRAPH_THREADS_CONFIG, useValue: { * apiUrl: environment.langGraphApiUrl, - * titleMetadataKey: 'thread_title', * }}, * ], * ``` + * + * The adapter expects backends to write the thread title to + * `metadata.title`. Spec 2026-05-19-llm-generated-labels-design.md + * originally proposed `metadata.thread_title` for cockpit caps but + * we converged on `title` to match the canonical demo and avoid a + * per-cap configuration knob. */ export interface LangGraphThreadsConfig { /** Base URL for the LangGraph Platform API. Accepts both absolute * URLs and relative `/api`-style paths. */ apiUrl: string; - /** Metadata key the backend writes the thread title to. Two - * conventions exist in the wild: - * - `'title'` — legacy / canonical demo - * - `'thread_title'` — spec 2026-05-19-llm-generated-labels-design - * Defaults to `'thread_title'`. */ - titleMetadataKey?: string; /** Fallback label for threads whose title hasn't been written yet * (e.g. created but never sent). Defaults to `'Untitled'`. */ titleFallback?: string; @@ -65,7 +64,6 @@ export class LangGraphThreadsAdapter { private readonly client: Client = inject(LANGGRAPH_CLIENT, { optional: true }) ?? createLangGraphClient(this.config.apiUrl); - private readonly titleKey: string = this.config.titleMetadataKey ?? 'thread_title'; private readonly fallback: string = this.config.titleFallback ?? 'Untitled'; private readonly _threads: WritableSignal = signal([]); @@ -153,7 +151,7 @@ export class LangGraphThreadsAdapter { } async rename(threadId: string, newTitle: string): Promise { - await this.client.threads.update(threadId, { metadata: { [this.titleKey]: newTitle } }); + await this.client.threads.update(threadId, { metadata: { title: newTitle } }); await this.refresh(); } @@ -206,7 +204,7 @@ export class LangGraphThreadsAdapter { private toThread(t: SdkThread): Thread { const meta = (t.metadata ?? {}) as Record; - const rawTitle = meta[this.titleKey]; + const rawTitle = meta['title']; const archived = meta['archived'] === true; const pinned = meta['pinned'] === true; const projectId = typeof meta['projectId'] === 'string' && (meta['projectId'] as string).length > 0