Skip to content

Commit ea867cf

Browse files
bloveclaude
andauthored
refactor(threads): converge on metadata.title; drop titleMetadataKey config (#506)
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) <noreply@anthropic.com>
1 parent d1a62cc commit ea867cf

8 files changed

Lines changed: 63 additions & 56 deletions

File tree

cockpit/chat/a2ui/python/src/graph.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -774,10 +774,11 @@ async def generate_title(state: MessagesState, config) -> dict:
774774
intent into 3-5 words and persist to LangGraph thread metadata so the
775775
sidenav shows something meaningful instead of a UUID slice.
776776
777-
Idempotent — skips when metadata.thread_title already exists. Errors
778-
are swallowed (title is a UX nicety, never a blocker). Runs after the
777+
Idempotent — skips when metadata.title already exists. Errors are
778+
swallowed (title is a UX nicety, never a blocker). Runs after the
779779
user-visible terminal node so it never blocks the response. See spec
780-
2026-05-19-llm-generated-labels-design.md.
780+
2026-05-19-llm-generated-labels-design.md (originally `thread_title`,
781+
converged to `title` for parity with the canonical demo + adapter).
781782
"""
782783
thread_id = (config.get("configurable") or {}).get("thread_id")
783784
if not thread_id:
@@ -790,7 +791,7 @@ async def generate_title(state: MessagesState, config) -> dict:
790791
try:
791792
client = get_client(url=sdk_url)
792793
thread = await client.threads.get(thread_id)
793-
if (thread.get("metadata") or {}).get("thread_title"):
794+
if (thread.get("metadata") or {}).get("title"):
794795
return {}
795796
first_user = next(
796797
(m for m in state["messages"] if getattr(m, "type", None) == "human"),
@@ -808,7 +809,7 @@ async def generate_title(state: MessagesState, config) -> dict:
808809
])
809810
title = (response.content or "").strip().strip('"').strip("'")[:80]
810811
if title:
811-
await client.threads.update(thread_id, metadata={"thread_title": title})
812+
await client.threads.update(thread_id, metadata={"title": title})
812813
except Exception as err: # noqa: BLE001 — title is a UX nicety; never block
813814
_logger.warning("Thread title generation failed: %s", err)
814815
return {}

cockpit/chat/threads/angular/src/app/app.config.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,11 @@ export const appConfig: ApplicationConfig = {
88
providers: [
99
provideAgent({ apiUrl: environment.langGraphApiUrl }),
1010
provideChat({}),
11-
// c-threads' Python graph writes the LLM-generated title to
12-
// metadata.thread_title (per spec 2026-05-19-llm-generated-labels-design).
11+
// The adapter expects metadata.title; the cap's generate_title
12+
// graph node writes there. No per-cap key override needed.
1313
{
1414
provide: LANGGRAPH_THREADS_CONFIG,
15-
useValue: {
16-
apiUrl: environment.langGraphApiUrl,
17-
titleMetadataKey: 'thread_title',
18-
},
15+
useValue: { apiUrl: environment.langGraphApiUrl },
1916
},
2017
],
2118
};

cockpit/chat/threads/angular/src/app/threads.component.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import { environment } from '../environments/environment';
1313
* ThreadsComponent demonstrates multi-thread conversation management
1414
* backed by the real LangGraph SDK. Consumes the shared
1515
* LangGraphThreadsAdapter from `@ngaf/langgraph` — same service the
16-
* canonical demo uses — configured for the `metadata.thread_title`
17-
* key that this cap's `generate_title` graph node writes (spec
18-
* 2026-05-19-llm-generated-labels-design). See app.config.ts for the
19-
* LANGGRAPH_THREADS_CONFIG provider.
16+
* canonical demo uses. Reads `metadata.title` written by this cap's
17+
* `generate_title` graph node (spec
18+
* 2026-05-19-llm-generated-labels-design.md, converged on `title`
19+
* after the original `thread_title` choice). See app.config.ts for
20+
* the LANGGRAPH_THREADS_CONFIG provider.
2021
*/
2122
@Component({
2223
selector: 'app-threads',
@@ -86,7 +87,7 @@ export class ThreadsComponent {
8687
void this.threadsSvc.refresh();
8788

8889
// Re-fetch when an agent run completes. The graph's generate_title
89-
// node writes metadata.thread_title on the first turn; refreshing
90+
// node writes metadata.title on the first turn; refreshing
9091
// on the running→idle transition surfaces it in the sidenav
9192
// without a manual reload.
9293
refreshOnRunEnd(this.agent, () => this.threadsSvc.refresh());

cockpit/chat/threads/python/src/graph.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
Conversational agent with inline thread-title generation. Each new
55
thread gets an LLM-generated 3-5 word title written to LangGraph
66
thread metadata on the first turn (idempotent — subsequent turns skip
7-
the write). The chat-threads frontend reads `metadata.thread_title`
8-
from `client.threads.search()` and displays it in the sidenav.
7+
the write). The chat-threads frontend reads `metadata.title` from
8+
`client.threads.search()` via LangGraphThreadsAdapter and displays
9+
it in the sidenav.
910
1011
Pattern D from spec 2026-05-19-llm-generated-labels-design.md: the
1112
generate_title node lives inline in this file (not extracted to a
@@ -37,9 +38,14 @@ async def generate_title(state: MessagesState, config) -> dict:
3738
intent into 3-5 words and persist to LangGraph thread metadata so the
3839
sidenav shows something meaningful instead of a UUID slice.
3940
40-
Idempotent — skips when metadata.thread_title already exists. Errors
41-
are swallowed (title is a UX nicety, never a blocker). Runs after the
41+
Idempotent — skips when metadata.title already exists. Errors are
42+
swallowed (title is a UX nicety, never a blocker). Runs after the
4243
user-visible turn so it never blocks the response.
44+
45+
Writes to `metadata.title` (matches the legacy examples/chat
46+
convention and the LangGraphThreadsAdapter default). Spec
47+
2026-05-19 originally chose `thread_title`; we converged on
48+
`title` to drop the per-cap `titleMetadataKey` config knob.
4349
"""
4450
thread_id = (config.get("configurable") or {}).get("thread_id")
4551
if not thread_id:
@@ -53,7 +59,7 @@ async def generate_title(state: MessagesState, config) -> dict:
5359
try:
5460
client = get_client(url=sdk_url)
5561
thread = await client.threads.get(thread_id)
56-
if (thread.get("metadata") or {}).get("thread_title"):
62+
if (thread.get("metadata") or {}).get("title"):
5763
return {}
5864
first_user = next(
5965
(m for m in state["messages"] if getattr(m, "type", None) == "human"),
@@ -71,7 +77,7 @@ async def generate_title(state: MessagesState, config) -> dict:
7177
])
7278
title = (response.content or "").strip().strip('"').strip("'")[:80]
7379
if title:
74-
await client.threads.update(thread_id, metadata={"thread_title": title})
80+
await client.threads.update(thread_id, metadata={"title": title})
7581
except Exception as e: # noqa: BLE001 — title is a UX nicety; never block
7682
# Don't break the run, but DO log. A bare pass has hidden a prod
7783
# bug in the sibling examples/chat graph where the title write was

docs/superpowers/specs/2026-05-19-llm-generated-labels-design.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,21 @@ export function a2uiActionLabel(content: string): string | null {
320320
- `_builder.add_edge("respond", "generate_title")` — both are node names registered earlier in the file. Consistent.
321321

322322
**Anti-pattern check:** zero hardcoded label tables. Zero topology magic. Zero cross-cap python imports. Each cap stays self-contained.
323+
324+
---
325+
326+
## Addendum 2026-05-21 — converged on `metadata.title`
327+
328+
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:
329+
330+
- `LangGraphThreadsAdapter` carried a `titleMetadataKey` config knob to bridge the two conventions
331+
- Each consumer had to remember which spelling its backend used
332+
- The canonical demo (`examples/chat/python`) writes `metadata.title` and predates this spec
333+
334+
Resolved by converging on `metadata.title` across all consumers:
335+
336+
- `cockpit/chat/threads/python` + `cockpit/chat/a2ui/python` graphs now write `metadata.title`
337+
- `LangGraphThreadsAdapter` reads `metadata.title` unconditionally; the `titleMetadataKey` config knob is gone
338+
- 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)
339+
340+
Pattern D (inline node per cap, no shared helper) stays intact — only the metadata key name changes.

examples/chat/angular/src/app/app.config.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,10 @@ export const appConfig: ApplicationConfig = {
1212
provideZonelessChangeDetection(),
1313
provideRouter(routes, withComponentInputBinding()),
1414
provideNgafTelemetry(environment.telemetry),
15-
// Configure the shared LangGraphThreadsAdapter. The canonical
16-
// demo's Python graph writes the title to `metadata.title` (the
17-
// legacy spelling — c-threads writes `metadata.thread_title`).
15+
// Configure the shared LangGraphThreadsAdapter.
1816
{
1917
provide: LANGGRAPH_THREADS_CONFIG,
20-
useValue: {
21-
apiUrl: environment.langGraphApiUrl,
22-
titleMetadataKey: 'title',
23-
},
18+
useValue: { apiUrl: environment.langGraphApiUrl },
2419
},
2520
],
2621
};

libs/langgraph/src/lib/threads/threads-adapter.spec.ts

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ function mockClient(searchReturn: unknown[] = []): {
2727
};
2828
}
2929

30-
function configure(client: Client, titleKey = 'thread_title'): LangGraphThreadsAdapter {
30+
function configure(client: Client): LangGraphThreadsAdapter {
3131
TestBed.configureTestingModule({
3232
providers: [
33-
{ provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x', titleMetadataKey: titleKey } },
33+
{ provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x' } },
3434
{ provide: LANGGRAPH_CLIENT, useValue: client },
3535
],
3636
});
@@ -40,12 +40,12 @@ function configure(client: Client, titleKey = 'thread_title'): LangGraphThreadsA
4040
describe('LangGraphThreadsAdapter', () => {
4141
beforeEach(() => TestBed.resetTestingModule());
4242

43-
it('maps SDK threads through the configured title metadata key', async () => {
43+
it('maps SDK threads via metadata.title', async () => {
4444
const { client } = mockClient([
4545
{
4646
thread_id: 't1',
4747
updated_at: '2026-05-20T00:00:00Z',
48-
metadata: { thread_title: 'Capital of Japan' },
48+
metadata: { title: 'Capital of Japan' },
4949
},
5050
]);
5151
const svc = configure(client);
@@ -55,15 +55,6 @@ describe('LangGraphThreadsAdapter', () => {
5555
]);
5656
});
5757

58-
it('honours an alternate title key (demo writes metadata.title)', async () => {
59-
const { client } = mockClient([
60-
{ thread_id: 't1', metadata: { title: 'Hello' } },
61-
]);
62-
const svc = configure(client, 'title');
63-
await svc.refresh();
64-
expect(svc.threads()[0].title).toBe('Hello');
65-
});
66-
6758
it('falls back to "Untitled" when title metadata is missing', async () => {
6859
const { client } = mockClient([{ thread_id: 't1', metadata: {} }]);
6960
const svc = configure(client);
@@ -93,19 +84,19 @@ describe('LangGraphThreadsAdapter', () => {
9384
expect(svc.threads().map(t => t.id)).toEqual(['p1', 'p2', 'unp']);
9485
});
9586

96-
it('rename() writes the configured title key', async () => {
87+
it('rename() writes metadata.title', async () => {
9788
const m = mockClient();
98-
const svc = configure(m.client, 'thread_title');
89+
const svc = configure(m.client);
9990
await svc.rename('t1', 'New title');
100-
expect(m.update).toHaveBeenCalledWith('t1', { metadata: { thread_title: 'New title' } });
91+
expect(m.update).toHaveBeenCalledWith('t1', { metadata: { title: 'New title' } });
10192
});
10293

10394
it('getThread() returns a mapped Thread when the SDK resolves', async () => {
10495
const m = mockClient();
10596
m.get.mockResolvedValue({
10697
thread_id: 'tx',
10798
updated_at: '2026-05-20T00:00:00Z',
108-
metadata: { thread_title: 'hello' },
99+
metadata: { title: 'hello' },
109100
});
110101
const svc = configure(m.client);
111102
const result = await svc.getThread('tx');

libs/langgraph/src/lib/threads/threads-adapter.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,20 @@ import { createLangGraphClient } from '../client/create-langgraph-client';
1212
* providers: [
1313
* { provide: LANGGRAPH_THREADS_CONFIG, useValue: {
1414
* apiUrl: environment.langGraphApiUrl,
15-
* titleMetadataKey: 'thread_title',
1615
* }},
1716
* ],
1817
* ```
18+
*
19+
* The adapter expects backends to write the thread title to
20+
* `metadata.title`. Spec 2026-05-19-llm-generated-labels-design.md
21+
* originally proposed `metadata.thread_title` for cockpit caps but
22+
* we converged on `title` to match the canonical demo and avoid a
23+
* per-cap configuration knob.
1924
*/
2025
export interface LangGraphThreadsConfig {
2126
/** Base URL for the LangGraph Platform API. Accepts both absolute
2227
* URLs and relative `/api`-style paths. */
2328
apiUrl: string;
24-
/** Metadata key the backend writes the thread title to. Two
25-
* conventions exist in the wild:
26-
* - `'title'` — legacy / canonical demo
27-
* - `'thread_title'` — spec 2026-05-19-llm-generated-labels-design
28-
* Defaults to `'thread_title'`. */
29-
titleMetadataKey?: string;
3029
/** Fallback label for threads whose title hasn't been written yet
3130
* (e.g. created but never sent). Defaults to `'Untitled'`. */
3231
titleFallback?: string;
@@ -65,7 +64,6 @@ export class LangGraphThreadsAdapter {
6564
private readonly client: Client = inject(LANGGRAPH_CLIENT, { optional: true })
6665
?? createLangGraphClient(this.config.apiUrl);
6766

68-
private readonly titleKey: string = this.config.titleMetadataKey ?? 'thread_title';
6967
private readonly fallback: string = this.config.titleFallback ?? 'Untitled';
7068

7169
private readonly _threads: WritableSignal<Thread[]> = signal<Thread[]>([]);
@@ -153,7 +151,7 @@ export class LangGraphThreadsAdapter {
153151
}
154152

155153
async rename(threadId: string, newTitle: string): Promise<void> {
156-
await this.client.threads.update(threadId, { metadata: { [this.titleKey]: newTitle } });
154+
await this.client.threads.update(threadId, { metadata: { title: newTitle } });
157155
await this.refresh();
158156
}
159157

@@ -206,7 +204,7 @@ export class LangGraphThreadsAdapter {
206204

207205
private toThread(t: SdkThread): Thread {
208206
const meta = (t.metadata ?? {}) as Record<string, unknown>;
209-
const rawTitle = meta[this.titleKey];
207+
const rawTitle = meta['title'];
210208
const archived = meta['archived'] === true;
211209
const pinned = meta['pinned'] === true;
212210
const projectId = typeof meta['projectId'] === 'string' && (meta['projectId'] as string).length > 0

0 commit comments

Comments
 (0)