Skip to content

Commit fdfbbf6

Browse files
authored
test(examples-chat): aimock research-subagent scenario, fix multi-turn loop (#330)
Captures parent (tool_call) + subagent (summary) + parent continuation (final text) into a single fixture file. The continuation entry uses match.hasToolResult=true so aimock returns the final text answer instead of looping back to the first-call tool_call response. Without this, the parent re-emits research up to the langgraph recursion limit and the test takes ~3 minutes wall-clock. Runner switched from mock.onMessage() to mock.addFixturesFromJSON() so the JSON fixture's richer match shapes (toolName, hasToolResult, turnIndex, etc.) survive the load path. Existing fixtures use just userMessage and keep working.
1 parent ba4904f commit fdfbbf6

3 files changed

Lines changed: 89 additions & 19 deletions

File tree

examples/chat/aimock-e2e/aimock-runner.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,30 @@ export interface AimockStartOptions {
1818
fixturePath: string;
1919
}
2020

21-
interface FixtureFile {
22-
fixtures: ReadonlyArray<{
23-
match: { userMessage: string };
24-
response: { content: string };
25-
}>;
26-
}
21+
// Raw JSON entry shape passes through to aimock's FixtureFileEntry — the
22+
// `match` block can carry richer discriminators (toolName, hasToolResult,
23+
// turnIndex, etc.) that are needed to distinguish a parent LLM's first call
24+
// from its continuation after a tool round. We don't narrow the shape here;
25+
// aimock's `addFixturesFromJSON` validates structure at load time.
26+
type FixtureFileEntry = Record<string, unknown>;
2727

28-
function loadFixtureEntries(fixturePath: string): FixtureFile['fixtures'] {
28+
function loadFixtureEntries(fixturePath: string): FixtureFileEntry[] {
2929
const stats = statSync(fixturePath);
30+
const out: FixtureFileEntry[] = [];
31+
const readFile = (full: string): void => {
32+
const raw = readFileSync(full, 'utf-8');
33+
const parsed = JSON.parse(raw) as { fixtures: FixtureFileEntry[] };
34+
for (const fx of parsed.fixtures) out.push(fx);
35+
};
3036
if (stats.isDirectory()) {
31-
const merged: FixtureFile['fixtures'][number][] = [];
3237
const files = readdirSync(fixturePath)
3338
.filter((f) => f.endsWith('.json'))
3439
.sort();
35-
for (const file of files) {
36-
const raw = readFileSync(join(fixturePath, file), 'utf-8');
37-
const parsed = JSON.parse(raw) as FixtureFile;
38-
for (const fx of parsed.fixtures) merged.push(fx);
39-
}
40-
return merged;
40+
for (const file of files) readFile(join(fixturePath, file));
41+
return out;
4142
}
42-
const raw = readFileSync(fixturePath, 'utf-8');
43-
const parsed = JSON.parse(raw) as FixtureFile;
44-
return parsed.fixtures;
43+
readFile(fixturePath);
44+
return out;
4545
}
4646

4747
export async function startAimock(opts: AimockStartOptions): Promise<AimockHandle> {
@@ -57,8 +57,8 @@ export async function startAimock(opts: AimockStartOptions): Promise<AimockHandl
5757
// Phase 1 unit-variance tables; the e2e harness is for final-state
5858
// invariants and cross-stack integration.
5959
const mock = new LLMock({ port: 0, chunkSize: 4096 });
60-
for (const fx of entries) {
61-
mock.onMessage(fx.match.userMessage, fx.response);
60+
if (entries.length > 0) {
61+
mock.addFixturesFromJSON(entries as never);
6262
}
6363
await mock.start();
6464

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "Use the research subagent to investigate the history and motivation behind Angular standalone components, then report back with a concise summary.",
6+
"hasToolResult": true
7+
},
8+
"response": {
9+
"content": "I ran the research subagent on \"History and motivation behind Angular standalone components\" and here is a concise summary.\n\n- Historical context\n - Angular originally required NgModules to declare components, directives, pipes, imports, and providers. This module-centric model was introduced early in Angular to organize and scope features.\n - The Ivy compiler/rendering pipeline (around Angular 9, 2019) decoupled component compilation and runtime from many NgModule responsibilities, making the module layer less technically necessary for many tasks.\n - Standalone components were introduced as an opt-in preview in Angular 14 (2022) and stabilized in Angular 15 (late 2022), with companion APIs (e.g., bootstrapApplication, loadComponent) enabling apps to run and lazy-load without NgModules.\n\n- What standalone components are\n - A component (or directive/pipe) marked with standalone: true that can declare its own imports and providers.\n - Standalone components can be bootstrapped directly, referenced in routes, and lazy-loaded with loadComponent \u2014 without being declared inside an NgModule.\n\n- Primary motivations\n - Reduce boilerplate and mental overhead associated with creating and maintaining NgModules.\n - Simplify app structure and make code more ergonomic and approachable, particularly for newcomers and small apps.\n - Enable better tree-shaking and potentially improved compilation/runtime performance by removing an extra indirection layer.\n - Improve ergonomics for lazy-loading, microfrontends, and unit testing (components can be used directly).\n - Allow incremental adoption: NgModules remain supported so teams can migrate at their own pace.\n\n- Practical implications\n - Developers can migrate incrementally to standalone components; existing NgModule-based apps continue to work.\n - New APIs (bootstrapApplication, loadComponent) support module-free bootstrapping and route-level lazy loading.\n - Simpler test setup and smaller, more self-contained component boundaries.\n\nIf you want, I can:\n- Show brief code examples (bootstrapping with bootstrapApplication, a standalone component declaration, using loadComponent in a route).\n- Summarize migration strategies from NgModules to standalone components."
10+
}
11+
},
12+
{
13+
"match": {
14+
"userMessage": "Use the research subagent to investigate the history and motivation behind Angular standalone components, then report back with a concise summary."
15+
},
16+
"response": {
17+
"toolCalls": [
18+
{
19+
"name": "research",
20+
"arguments": {
21+
"topic": "History and motivation behind Angular standalone components",
22+
"subagent_type": "research"
23+
}
24+
}
25+
]
26+
}
27+
},
28+
{
29+
"match": {
30+
"userMessage": "Topic: History and motivation behind Angular standalone components"
31+
},
32+
"response": {
33+
"content": "- Background: Angular originally required NgModules to declare components, imports and providers; Ivy (Angular 9, 2019) decoupled the rendering/compilation pipeline from NgModules, making many NgModule responsibilities redundant. \n- Timeline: the standalone-components idea was introduced as an opt-in preview in Angular 14 (2022) and stabilized in Angular 15 (late 2022), with supporting APIs (e.g., bootstrapApplication) to run apps without NgModules. \n- What they are: a component marked standalone: true that can carry its own imports/providers and be used directly in routes or bootstrapped without being declared in an NgModule (also applies to directives and pipes). \n- Motivation: reduce boilerplate and mental overhead, simplify app structure, improve tree-shaking and compilation speed, and make lazy-loading/microfrontend patterns and unit testing easier and more ergonomic. \n- Practical effect: developers can incrementally adopt standalone components (NgModules remain supported), use loadComponent for route-level lazy loading, and simplify bootstrapping and test setup while preserving backward compatibility."
34+
}
35+
}
36+
]
37+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// SPDX-License-Identifier: MIT
2+
import { test, expect } from '@playwright/test';
3+
4+
const PROMPT =
5+
'Use the research subagent to investigate the history and motivation behind ' +
6+
'Angular standalone components, then report back with a concise summary.';
7+
8+
test('research subagent: parent dispatches research, subagent content surfaces in the bubble', async ({
9+
page,
10+
}) => {
11+
await page.goto('/embed');
12+
13+
const input = page.getByRole('textbox', { name: /message|prompt/i });
14+
await input.fill(PROMPT);
15+
await page.getByRole('button', { name: /send/i }).click();
16+
17+
// The chat-tool-calls primitive renders a button/chip labeled "Called research"
18+
// (or similar) once the parent dispatches the tool. With aimock the subagent
19+
// runs essentially instantly, so we don't try to catch the transient
20+
// <chat-subagents> panel — instead we assert on the durable
21+
// tool-call-completion chip and on subagent-emitted content reaching the bubble.
22+
const researchChip = page.getByRole('button', { name: /research/i }).first();
23+
await expect(researchChip).toBeVisible({ timeout: 45_000 });
24+
25+
// The captured subagent summary mentions standalone components and NgModule.
26+
// Assert one of those terms appears in the conversation body — proves the
27+
// subagent's LLM response made it through the graph back into the chat.
28+
const conversation = page.locator('chat-message-list, chat-window').first();
29+
await expect.poll(
30+
async () => (await conversation.innerText()).toLowerCase(),
31+
{ timeout: 45_000 },
32+
).toMatch(/standalone components|ngmodule/i);
33+
});

0 commit comments

Comments
 (0)