Skip to content

Commit f4c7b80

Browse files
bloveclaude
andauthored
fix(cockpit-langgraph): make the subgraphs example demonstrate real nesting (#838)
The `cockpit/langgraph/subgraphs` example advertised subagent delegation it could never perform. `app.config.ts` set no `subagentToolNames`, and the graph added a compiled subgraph as a plain node — which emits a `research:<uuid>` namespace, not `tools:<id>`. The SubagentTracker only registers delegation tool calls, so `agent.subagents()` was permanently empty and the sidebar always rendered "No active subagents". The manual e2e asserted exactly that empty state and nothing else. The parent's edges were unconditional, so nothing decided anything, and both graphs were bare `MessagesState` with no boundary. Kept the plain subgraph-as-node pattern rather than converting to the tool-call path: `cockpit/chat/subagents` already demonstrates tool-call delegation end to end, and this is the repo's only example of LangGraph's actual composition primitive. The website docs already state the rule (docs/langgraph/guides/subgraphs.mdx:114 — "Plain subgraph nodes do not appear in this map"); the example now agrees with them instead of contradicting them. Backend: - `orchestrate` makes a structured routing decision; a conditional edge enters the child graph or skips straight to `answer`. - The child's state (`ResearchState`) has no `messages` key, so it exchanges only `research_topic` / `research_brief` with the parent and can neither read nor append to the transcript. - `answer` is the sole transcript-writing node. Frontend: - Sidebar reads the parent's own state via a typed `agent.value()` instead of the map that can never fill, and says plainly why. - `transcriptNodeNames: ['answer']`. Verified live: without it the message list transiently grows to 3 as the child's tokens merge in and its internal brief renders as a chat bubble, before the parent's `values` event collapses it back to 2. Self-correcting end state, so no final-state assertion catches it. Tests: three e2e specs replacing the smoke test — the research branch populates the sidebar, the child's brief never reaches the transcript (paired with a positive sidebar assertion so it can't pass vacuously), and the direct branch skips the child and still answers. The manual live-LLM spec asserts the branch that ran rather than an empty state. Verified: e2e 3/3 green; production build green; both branches driven against a real model in Chrome, with the transcript boundary checked from the live DOM. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d2e5ce7 commit f4c7b80

12 files changed

Lines changed: 567 additions & 173 deletions

File tree

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,40 @@
11
{
22
"fixtures": [
33
{
4-
"match": {
5-
"userMessage": "Hello"
6-
},
4+
"match": { "responseFormat": "json_schema", "userMessage": "LangGraph checkpointing" },
75
"response": {
8-
"content": "Hello \u2014 nice to meet you. What would you like help with today?\n\nTell me the specific question or topic and any preferences (depth: quick summary vs deep dive, date range, types of sources, format: bullet list, tutorial, pros/cons, citations). Example: \u201cCompare CRISPR vs base editors for somatic therapy, include clinical trials since 2020 and cite sources.\u201d\n\nOnce you reply I\u2019ll prepare and run a focused research query and return the findings."
6+
"content": {
7+
"needs_research": true,
8+
"topic": "LangGraph checkpointing: what a checkpointer persists and when"
9+
}
10+
}
11+
},
12+
{
13+
"match": { "responseFormat": "json_schema", "userMessage": "what can you do" },
14+
"response": {
15+
"content": {
16+
"needs_research": false,
17+
"topic": ""
18+
}
19+
}
20+
},
21+
{
22+
"match": { "systemMessage": "Research Subgraph", "userMessage": "LangGraph checkpointing" },
23+
"response": {
24+
"content": "- A checkpointer writes one state snapshot per super-step.\n- Snapshots are keyed by thread_id, so each conversation replays independently.\n- Interrupts resume from the snapshot taken immediately before the pause.\n- InMemorySaver is for development; Postgres and SQLite savers persist across restarts."
25+
}
26+
},
27+
{
28+
"match": { "systemMessage": "Orchestrator Agent", "userMessage": "LangGraph checkpointing" },
29+
"response": {
30+
"content": "Checkpointing saves your graph's state as it runs, so a conversation can pause and pick up exactly where it left off. Each thread gets its own history, and durable savers keep it around across restarts."
31+
}
32+
},
33+
{
34+
"match": { "systemMessage": "Orchestrator Agent", "userMessage": "what can you do" },
35+
"response": {
36+
"content": "Hi! I answer questions, and when one needs a factual deep dive I hand the topic to a research subgraph before replying. Ask me something specific and you'll see it happen."
937
}
1038
}
1139
]
12-
}
40+
}
Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,45 @@
1+
// SPDX-License-Identifier: MIT
2+
//
3+
// Manual (live-LLM) counterpart to subgraphs.spec.ts. Run against a real
4+
// OpenAI key with the example served on its cockpit port — the routing
5+
// decision is a genuine model call here, so assertions check the branch that
6+
// actually ran rather than baking in a fixture's answer.
17
import { expect, test } from '@playwright/test';
28

3-
test.describe('LangGraph Subgraphs Example', () => {
9+
test.describe('LangGraph Subgraphs Example (live)', () => {
410
test.beforeEach(async ({ page }) => {
511
await page.goto('http://localhost:4305');
612
await page.waitForSelector('app-subgraphs', { state: 'attached' });
713
});
814

9-
test('renders the chat interface with subagent sidebar', async ({ page }) => {
10-
await expect(page.locator('chat')).toBeVisible();
11-
await expect(page.locator('textarea[name="messageText"]')).toBeVisible();
12-
await expect(page.locator('text=No active subagents')).toBeVisible();
15+
test('a research question routes into the child graph', async ({ page }) => {
16+
await page.fill('textarea[name="messageText"]', 'How does LangGraph checkpointing work?');
17+
await page.click('button[type="submit"]');
18+
19+
const panel = page.getByTestId('subgraph-panel');
20+
await expect(panel.getByTestId('route')).toContainText('Nested', { timeout: 60_000 });
21+
await expect(panel.getByTestId('research-topic')).not.toBeEmpty({ timeout: 60_000 });
22+
await expect(panel.getByTestId('research-brief')).not.toBeEmpty({ timeout: 60_000 });
23+
24+
const assistant = page
25+
.locator('chat-message[data-role="assistant"][data-streaming="false"]')
26+
.last();
27+
await expect(assistant).toBeAttached({ timeout: 60_000 });
28+
await expect(assistant.locator('.chat-md')).not.toBeEmpty();
1329
});
1430

15-
test('sends a message and receives a response', async ({ page }) => {
16-
await page.fill('textarea[name="messageText"]', 'hello');
31+
test('a greeting skips the child graph', async ({ page }) => {
32+
await page.fill('textarea[name="messageText"]', 'hi there');
1733
await page.click('button[type="submit"]');
18-
await expect(page.locator('.chat-md').first()).toBeVisible({ timeout: 30000 });
19-
await expect(page.locator('.chat-md').first()).not.toBeEmpty({ timeout: 30000 });
34+
35+
const assistant = page
36+
.locator('chat-message[data-role="assistant"][data-streaming="false"]')
37+
.last();
38+
await expect(assistant).toBeAttached({ timeout: 60_000 });
39+
await expect(assistant.locator('.chat-md')).not.toBeEmpty();
40+
41+
const panel = page.getByTestId('subgraph-panel');
42+
await expect(panel.getByTestId('route')).toContainText('Direct');
43+
await expect(panel.getByTestId('research-brief')).toHaveCount(0);
2044
});
2145
});
Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,58 @@
11
// SPDX-License-Identifier: MIT
22
import { test, expect } from '@playwright/test';
3-
import { submitAndWaitForResponse } from '@threadplane-internal/e2e-harness';
43

5-
test('subgraphs: hello prompt produces assistant turn', async ({ page }) => {
6-
const bubble = await submitAndWaitForResponse(page, 'Hello');
7-
// Smoke: backend booted, aimock replayed fixture, assistant bubble
8-
// finalized (data-streaming="false") and is present in the DOM.
9-
await expect(bubble).toBeVisible();
4+
// Distinctive line from the research subgraph's brief. It exists only in the
5+
// child graph's `research_brief` output, never in the parent's answer — which
6+
// is what makes it usable as a probe for the state boundary.
7+
const BRIEF_MARKER = 'one state snapshot per super-step';
8+
9+
test.describe('cockpit subgraphs: conditional nesting', () => {
10+
test('a research question routes into the child graph and shows its brief', async ({ page }) => {
11+
await page.goto('/');
12+
await page.getByText('Ask something that needs research').click();
13+
14+
const finalAssistant = page
15+
.locator('chat-message[data-role="assistant"][data-streaming="false"]')
16+
.last();
17+
await expect(finalAssistant).toBeAttached({ timeout: 30_000 });
18+
19+
const panel = page.getByTestId('subgraph-panel');
20+
await expect(panel.getByTestId('route')).toContainText('Nested');
21+
await expect(panel.getByTestId('research-topic')).toContainText('checkpointer persists');
22+
await expect(panel.getByTestId('research-brief')).toContainText(BRIEF_MARKER);
23+
await expect(finalAssistant).toContainText('Checkpointing saves');
24+
});
25+
26+
test("the child's brief never reaches the transcript", async ({ page }) => {
27+
await page.goto('/');
28+
await page.getByText('Ask something that needs research').click();
29+
30+
// Sidebar has it (proves the assertion below is not vacuous — the brief
31+
// was produced and delivered, it just went somewhere the transcript isn't).
32+
await expect(page.getByTestId('research-brief')).toContainText(BRIEF_MARKER, {
33+
timeout: 30_000,
34+
});
35+
await expect(
36+
page.locator('chat-message[data-role="assistant"][data-streaming="false"]').last(),
37+
).toBeAttached({ timeout: 30_000 });
38+
39+
// Scan every rendered message, not just the last one.
40+
await expect(page.locator('chat-message').filter({ hasText: BRIEF_MARKER })).toHaveCount(0);
41+
await expect(page.locator('chat-message')).toHaveCount(2); // the ask + the parent's answer
42+
});
43+
44+
test('a greeting skips the child graph and still answers', async ({ page }) => {
45+
await page.goto('/');
46+
await page.getByText('Ask something that does not').click();
47+
48+
const finalAssistant = page
49+
.locator('chat-message[data-role="assistant"][data-streaming="false"]')
50+
.last();
51+
await expect(finalAssistant).toBeAttached({ timeout: 30_000 });
52+
await expect(finalAssistant).toContainText('I answer questions');
53+
54+
const panel = page.getByTestId('subgraph-panel');
55+
await expect(panel.getByTestId('route')).toContainText('Direct');
56+
await expect(panel.getByTestId('research-brief')).toHaveCount(0);
57+
});
1058
});
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# LangGraph Subgraphs (Angular)
22

3-
This capability demonstrates composing LangGraph subgraphs — independent graphs invoked as nodes inside a parent graph — using the `@threadplane/chat` Angular component library. The `<chat-subagent-card>` renders a live status card for each active subgraph invocation, letting the user see which specialised agent is running and what it has produced.
3+
This capability demonstrates LangGraph subgraph composition — a compiled child
4+
graph added to a parent graph as a plain node — rendered with the
5+
`@threadplane/chat` Angular component library.
46

5-
Key components used: `<chat>`, `<chat-subagent-card>`. Cards appear in the message feed as the parent graph delegates work to subgraphs; each card shows the subgraph name, its streamed output, and a completion badge when the subgraph finishes.
7+
The parent orchestrator routes conditionally: requests that need a factual
8+
deep dive enter the `research` child graph first, everything else answers
9+
directly. The child graph's state has no `messages` key, so it exchanges only
10+
`research_topic` and `research_brief` with the parent and never touches the
11+
transcript.
12+
13+
The sidebar reads the parent graph's own state through `agent.value()` to show
14+
which branch ran and what the child returned. It deliberately does **not** use
15+
`agent.subagents()`: that signal is populated only by delegation *tool calls*
16+
(`subagentToolNames` + `subagent_type`), not by plain subgraph nodes. For that
17+
pattern see the Chat Subagents capability.
18+
19+
Key components used: `<chat>`. `provideAgent({ transcriptNodeNames: ['answer'] })`
20+
keeps the router's and the subgraph's tokens out of the chat transcript.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-License-Identifier: MIT
2+
import { createAgentRef } from '@threadplane/chat';
3+
4+
/**
5+
* Parent graph state for the subgraphs cockpit.
6+
*
7+
* `research_topic` and `research_brief` are the two keys the parent shares
8+
* with the compiled child graph — writing a topic is what routes execution
9+
* into the subgraph, and the brief is what comes back out. The child's own
10+
* state has no `messages` key, which is why nothing it produces reaches the
11+
* transcript.
12+
*/
13+
export interface SubgraphsState {
14+
messages: unknown[];
15+
research_topic: string;
16+
research_brief: string;
17+
}
18+
19+
/**
20+
* Typed DI handle for the subgraphs agent.
21+
* Wire with `provideAgent(SUBGRAPHS_AGENT, { ... })` and inject with
22+
* `injectAgent(SUBGRAPHS_AGENT)` to get `LangGraphAgent<SubgraphsState>`.
23+
*/
24+
export const SUBGRAPHS_AGENT = createAgentRef<SubgraphsState>('subgraphs');

cockpit/langgraph/subgraphs/angular/src/app/app.config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,25 @@ import { ApplicationConfig } from '@angular/core';
33
import { provideAgent } from '@threadplane/langgraph';
44
import { provideChat } from '@threadplane/chat';
55
import { environment } from '../environments/environment';
6+
import { SUBGRAPHS_AGENT } from './agent-ref';
67

78
export const appConfig: ApplicationConfig = {
89
providers: [
9-
provideAgent({
10+
provideAgent(SUBGRAPHS_AGENT, {
1011
apiUrl: environment.langGraphApiUrl,
1112
assistantId: environment.streamingAssistantId,
13+
// Only the parent's `answer` node writes the user-facing turn.
14+
//
15+
// This is load-bearing, and its failure mode is mid-stream rather than
16+
// final-state. LangGraph emits the child's tokens under a
17+
// `research:<uuid>` namespace, and that namespace is NOT a subagent
18+
// namespace (`tools:`), so the bridge merges those tokens into the
19+
// transcript as they arrive. Verified against a live model: without
20+
// this option the message list transiently grows to 3 — the child's
21+
// internal brief renders as its own chat bubble — before the parent's
22+
// authoritative `values` event collapses it back to 2. Because the end
23+
// state self-corrects, a final-state e2e assertion cannot catch it.
24+
transcriptNodeNames: ['answer'],
1225
}),
1326
provideChat({}),
1427
],

0 commit comments

Comments
 (0)