Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
# classifier walk.
- run: npm ci
- name: Test CI scope classifier
run: node --test scripts/ci-scope.spec.mjs scripts/cockpit-matrix.spec.mjs scripts/cockpit-frontend-matrix.spec.mjs scripts/cockpit-ports.spec.mjs scripts/cockpit-runtime-bridge-coverage.spec.mjs
run: node --test scripts/ci-scope.spec.mjs scripts/cockpit-matrix.spec.mjs scripts/cockpit-frontend-matrix.spec.mjs scripts/cockpit-ports.spec.mjs scripts/cockpit-runtime-bridge-coverage.spec.mjs scripts/react-cockpit/ag-ui-client-tools-configuration.spec.mjs scripts/react-cockpit/ag-ui-client-tools-preparation.spec.mjs
- name: Detect changed CI surfaces
id: scope
run: |
Expand Down Expand Up @@ -705,6 +705,11 @@ jobs:
run: |
npx nx test "${{ matrix.cap.angular }}" --reporters=default --skip-nx-cache
npx nx lint "${{ matrix.cap.angular }}" --skip-nx-cache
- name: Verify canonical client tools fixture ownership
if: matrix.cap.angular == 'cockpit-ag-ui-client-tools-react'
run: |
npx nx run cockpit-ag-ui-client-tools-react:prepare-fixture --skip-nx-cache
node --test scripts/react-cockpit/ag-ui-client-tools-fixture.spec.mjs
- name: nx e2e ${{ matrix.cap.angular }}
run: npx nx e2e "${{ matrix.cap.angular }}" --skip-nx-cache
- name: Upload Playwright trace on failure
Expand Down Expand Up @@ -748,6 +753,8 @@ jobs:
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57
with:
python-version: '3.12'
- name: Prepare frozen canonical client tools browser producer
run: node scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
Expand Down
2 changes: 2 additions & 0 deletions apps/website/content/AGENTS.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ Build AG-UI Tool Views with `npx nx build cockpit-ag-ui-tool-views-react` and ve

Build AG-UI JSON Render with `npx nx build cockpit-ag-ui-json-render-react` and verify with `npx nx e2e cockpit-ag-ui-json-render-react`; its existing shared route is `/ag-ui/json-render/agent`. The browser proof uses the actual compiled graph with frozen providers.

AG-UI Client Tools has an experimental native React preview at https://threadplane.ai/docs/ag-ui/guides/client-tools?frontend=react. Exactly five browser tools demonstrate simulated weather, a three-second cancellable status check, local weather cards, terminal snapshots, and explicit fictional booking decisions. Native sessions own the typed catalog and physical continuations; local execution is separate from delivery states unsent, sent-unconfirmed, and observed. weather_card follows up; weather_snapshot ends locally without an automatic final assistant and the next explicit human carries its result without replay. Booking resolves a browser promise, not native pause/resume. Stop revokes controls while retaining last captured observations as historical evidence; unknown calls, native pauses, errors, and uncertainty conservatively require New. First Send creates a UUID lazily; New stays local until Send. No automatic reconnect, replay, saved restoration, durable store, real weather, or real reservation is claimed. Angular retains the same canonical Python backend, stock model, and prompt. React, core, content, and the neutral AG-UI candidate remain private source-build packages, not public npm native APIs. Build with `npx nx build cockpit-ag-ui-client-tools-react`; verify with `npx nx e2e cockpit-ag-ui-client-tools-react`. The e2e target prepares its frozen uv/Python 3.12 canonical environment; deterministic offline scripted model responses verify the actual graph and locked adapter, not a live provider request. Docs, Code, and Run share the actual sources and native /ag-ui/client-tools/agent route.

AG-UI Subagents has an experimental React preview at https://threadplane.ai/docs/ag-ui/guides/subagents?frontend=react. Native child observations stay under their exact parent task and original assistant. Captured child text remains read-only when final root snapshots remove it; requests retain complete native parent history. Only confirmed current root success with all causal child completions permits a follow-up. New clears cards lazily; Stop labels incomplete observations stopped. Unknown attribution, orphan text and uncertain outcomes require New, without replay or child execution controls. Provider errors stay out of the display.

Build with `npx nx build cockpit-ag-ui-subagents-react` and verify with `npx nx e2e cockpit-ag-ui-subagents-react`; the existing shared route is `/ag-ui/subagents/agent`. Browser proofs use the actual compiled graph and child bridge with frozen providers.
Expand Down
2 changes: 2 additions & 0 deletions apps/website/content/CLAUDE.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ Build AG-UI Tool Views with `npx nx build cockpit-ag-ui-tool-views-react` and ve

Build AG-UI JSON Render with `npx nx build cockpit-ag-ui-json-render-react` and verify with `npx nx e2e cockpit-ag-ui-json-render-react`; its existing shared route is `/ag-ui/json-render/agent`. The browser proof uses the actual compiled graph with frozen providers.

AG-UI Client Tools has an experimental native React preview at https://threadplane.ai/docs/ag-ui/guides/client-tools?frontend=react. Exactly five browser tools demonstrate simulated weather, a three-second cancellable status check, local weather cards, terminal snapshots, and explicit fictional booking decisions. Native sessions own the typed catalog and physical continuations; local execution is separate from delivery states unsent, sent-unconfirmed, and observed. weather_card follows up; weather_snapshot ends locally without an automatic final assistant and the next explicit human carries its result without replay. Booking resolves a browser promise, not native pause/resume. Stop revokes controls while retaining last captured observations as historical evidence; unknown calls, native pauses, errors, and uncertainty conservatively require New. First Send creates a UUID lazily; New stays local until Send. No automatic reconnect, replay, saved restoration, durable store, real weather, or real reservation is claimed. Angular retains the same canonical Python backend, stock model, and prompt. React, core, content, and the neutral AG-UI candidate remain private source-build packages, not public npm native APIs. Build with `npx nx build cockpit-ag-ui-client-tools-react`; verify with `npx nx e2e cockpit-ag-ui-client-tools-react`. The e2e target prepares its frozen uv/Python 3.12 canonical environment; deterministic offline scripted model responses verify the actual graph and locked adapter, not a live provider request. Docs, Code, and Run share the actual sources and native /ag-ui/client-tools/agent route.

AG-UI Subagents has an experimental React preview at https://threadplane.ai/docs/ag-ui/guides/subagents?frontend=react. Native child observations stay under their exact parent task and original assistant. Captured child text remains read-only when final root snapshots remove it; requests retain complete native parent history. Only confirmed current root success with all causal child completions permits a follow-up. New clears cards lazily; Stop labels incomplete observations stopped. Unknown attribution, orphan text and uncertain outcomes require New, without replay or child execution controls. Provider errors stay out of the display.

Build with `npx nx build cockpit-ag-ui-subagents-react` and verify with `npx nx e2e cockpit-ag-ui-subagents-react`; the existing shared route is `/ag-ui/subagents/agent`. Browser proofs use the actual compiled graph and child bridge with frozen providers.
Expand Down
177 changes: 177 additions & 0 deletions apps/website/e2e/react-ag-ui-client-tools-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { writeFile } from 'node:fs/promises';
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
const route = '/docs/ag-ui/guides/client-tools';
const origin = 'http://127.0.0.1:4640';
const names = ['get_weather', 'slow_status_check', 'weather_card', 'weather_snapshot', 'confirm_booking'];
interface Message { id: string; role: string; content: string; toolCallId?: string; toolCalls?: { id: string }[] }
interface RecordRow {
body: { threadId: string; runId: string; messages: Message[]; tools: { name: string }[] };
events: { type: string; messages?: Message[] }[];
observations: { socketAttempts: string[]; modelMessages: { type: string; id?: string; tool_call_id?: string; content: string }[][] };
}
async function records(request: APIRequestContext): Promise<RecordRow[]> {
return (await request.get(origin + '/__requests')).json();
}
async function hydrated(page: Page, mode: string) {
await expect(page.locator('[data-workspace-shell]')).toHaveAttribute('data-hydrated', 'true');
await expect(page.locator('[data-workspace-shell]')).toHaveAttribute('data-workspace-mode', mode);
}
async function send(page: Page, text: string) {
const frame = page.frameLocator('iframe');
await frame.getByLabel('Message', { exact: true }).fill(text);
await frame.getByRole('button', { name: 'Send', exact: true }).click();
}
function native(wire: RecordRow[], count: number) {
expect(wire).toHaveLength(count);
expect(new Set(wire.map(row => row.body.runId)).size).toBe(count);
for (const row of wire) {
expect(row.body.tools.map(tool => tool.name)).toEqual(names);
expect(row.observations.socketAttempts).toEqual([]);
const model = row.observations.modelMessages.at(-1) ?? [];
const snapshot = row.events.filter(event => event.type === 'MESSAGES_SNAPSHOT').at(-1)?.messages ?? [];
for (const result of row.body.messages.filter(message => message.role === 'tool')) {
expect(row.body.messages.filter(message => message.toolCalls?.some(call => call.id === result.toolCallId))).toHaveLength(1);
expect(model.filter(message => message.type === 'tool' && message.id === result.id && message.tool_call_id === result.toolCallId && message.content === result.content)).toHaveLength(1);
expect(snapshot.filter(message => message.role === 'tool' && message.id === result.id && message.content === result.content)).toHaveLength(1);
}
}
}
test.describe('native React AG-UI Client Tools workspace', () => {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ request }) => {
test.skip(Boolean(process.env['BASE_URL']), 'Canonical offline ownership is verified locally');
expect((await request.post(origin + '/__reset')).ok()).toBe(true);
});
test.afterEach(async ({ page, request }, info) => {
if (process.env['BASE_URL']) return;
// Both success and assertion failure dispose the iframe before closing its exact worker owner.
try {
await page.goto('about:blank');
const wire = await records(request);
await writeFile(info.outputPath('native-wire.json'), JSON.stringify(wire, null, 2));
} finally {
const stats = await (await request.post(origin + '/__finish')).json();
await writeFile(info.outputPath('owned-fixture-receipt.json'), JSON.stringify(stats, null, 2));
expect(Object.values(stats.closed.at(-1).closure).every(Boolean)).toBe(true);
}
});
test('actual Docs, Code and Run retain native identity, source bytes and five inert presets', async ({ page, request }, info) => {
await page.goto(route + '?frontend=react&topicProof=b4');
await hydrated(page, 'Docs');
await expect(page.getByRole('heading', { name: 'React AG-UI client tools preview', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'React AG-UI streaming preview', exact: true })).toHaveCount(0);
await expect(page.locator('[data-example-file="cockpit/ag-ui/client-tools/react/src/tools.ts"]')).toBeVisible();
await page.screenshot({ path: info.outputPath('docs-desktop.png') });
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: info.outputPath('docs-mobile.png') });
await page.setViewportSize({ width: 1280, height: 900 });
await page.locator('[data-workspace-desktop-navigation]').getByRole('button', { name: 'Code', exact: true }).click();
await hydrated(page, 'Code');
const tree = page.getByRole('complementary', { name: 'File tree' });
for (const file of ['app.tsx', 'tools.ts', 'application.ts', 'projection.ts', 'weather-card.tsx', 'confirm-booking.tsx', 'connection.ts', 'main.tsx', 'styles.css', 'project.json', 'graph.py', 'server.py', 'client-tools.md']) {
await expect(tree.getByRole('button', { name: file, exact: true })).toBeVisible();
}
await expect(tree).not.toContainText(/fixture|scripted|\.spec\.|type-test/);
for (const [file, text] of [['tools.ts', 'FunctionTool'], ['application.ts', 'createClientToolsApplication'], ['connection.ts', 'createSession'], ['graph.py', 'bind_client_tools'], ['client-tools.md', 'weather_card']]) {
await tree.getByRole('button', { name: file, exact: true }).click();
await expect(page.getByRole('tabpanel')).toContainText(text);
}
await page.screenshot({ path: info.outputPath('code-desktop.png') });
await page.locator('[data-workspace-desktop-navigation]').getByRole('button', { name: /^Run(?:,|$)/ }).click();
await hydrated(page, 'Run');
await expect(page.locator('iframe')).toHaveAttribute('src', /^http:\/\/localhost:4640\/\?cockpit_did=/);
const frame = page.frameLocator('iframe');
await expect(frame.getByRole('status')).toHaveText('Ready.');
for (const label of ['Weather result', 'Slow status', 'Weather card', 'Terminal snapshot', 'Fictional booking']) {
await frame.getByRole('button', { name: label, exact: true }).click();
await expect(frame.getByLabel('Message', { exact: true })).not.toHaveValue('');
expect(await records(request)).toEqual([]);
}
await send(page, 'Use weather_card');
await expect(frame.getByRole('status')).toHaveText('Command complete.');
const wire = await records(request); native(wire, 2);
const result = wire[1].body.messages.find(message => message.role === 'tool');
expect(result?.content).toBe('{"displayed":true}');
const owner = wire[1].body.messages.find(message => message.toolCalls?.some(call => call.id === result?.toolCallId));
await expect(frame.locator(`[data-native-message="${owner?.id}"] [data-native-call="${result?.toolCallId}"]`)).toHaveCount(1);
const card = frame.getByRole('region', { name: 'Simulated weather for San Francisco' });
for (const value of ['68°F', 'Sunny', '55%', '8 mph']) await expect(card).toContainText(value);
await expect(frame.getByRole('region', { name: 'Local tool evidence' })).toContainText('Delivery: observed');
await page.screenshot({ path: info.outputPath('run-desktop.png') });
await page.setViewportSize({ width: 390, height: 844 });
await card.scrollIntoViewIfNeeded();
await page.screenshot({ path: info.outputPath('run-mobile.png') });
await card.screenshot({ path: info.outputPath('weather-mobile.png') });
await writeFile(info.outputPath('run-dom.json'), JSON.stringify({ conversation: await frame.getByRole('region', { name: 'Conversation', exact: true }).innerText(), reading: await card.innerText() }, null, 2));
});
test('terminal result is unsent until the next explicit human and New stays lazy', async ({ page, request }) => {
await page.goto(route + '?frontend=react&mode=run');
await send(page, 'Use weather_snapshot');
const frame = page.frameLocator('iframe');
await expect(frame.getByRole('status')).toHaveText('Command complete.');
native(await records(request), 1);
await expect(frame.getByRole('region', { name: 'Local tool evidence' })).toContainText('Delivery: unsent');
await send(page, 'Plain next human');
await expect(frame.getByRole('status')).toHaveText('Command complete.');
const wire = await records(request); native(wire, 2);
expect(wire[1].body.messages.filter(message => message.role === 'tool')).toHaveLength(1);
await frame.getByRole('button', { name: 'New conversation', exact: true }).click();
expect(await records(request)).toHaveLength(2);
await send(page, 'Plain fresh human');
await expect(frame.getByRole('status')).toHaveText('Command complete.');
const after = await records(request); native(after, 3);
expect(after[2].body.threadId).not.toBe(wire[0].body.threadId);
expect(after[2].body.messages).toHaveLength(1);
});
test('explicit fictional booking continues once and Stop revokes slow work', async ({ page, request }, info) => {
await page.goto(route + '?frontend=react&mode=run');
await send(page, 'Use confirm_booking');
const frame = page.frameLocator('iframe');
const panel = frame.getByRole('region', { name: 'Fictional booking' });
await expect(panel).toContainText('No real reservation');
native(await records(request), 1);
await frame.getByRole('button', { name: 'Confirm', exact: true }).click();
await expect(frame.getByRole('status')).toHaveText('Command complete.');
const wire = await records(request); native(wire, 2);
expect(wire[1].body.messages.filter(message => message.role === 'tool').map(message => JSON.parse(message.content))).toEqual([{ confirmed: true }]);
await panel.screenshot({ path: info.outputPath('fictional-booking.png') });
await frame.getByRole('button', { name: 'New conversation', exact: true }).click();
await send(page, 'Use slow_status_check');
await expect(frame.getByRole('region', { name: 'Local tool evidence' })).toContainText('Local execution: running');
await frame.getByRole('button', { name: 'Stop', exact: true }).click();
await expect(frame.getByRole('status')).toHaveText('Start a new conversation to continue.');
await page.waitForTimeout(3200);
expect(await records(request)).toHaveLength(3);
await expect(frame.locator('main')).not.toContainText('"status":"complete"');
});
test('history, reload, frontend changes and route navigation dispose controls and retain canonical defaults', async ({ page, request }) => {
await page.goto(route + '?frontend=react&mode=run&topicProof=b4');
await send(page, 'Use confirm_booking');
const frame = page.frameLocator('iframe');
await expect(frame.getByRole('status')).toHaveText('Waiting for your fictional booking decision…');
const oldFrame = await page.locator('iframe').elementHandle();
await page.getByLabel('Example UI').selectOption('angular');
await expect(page).not.toHaveURL(/frontend=react/);
await expect(page).toHaveURL(/topicProof=b4/);
expect(await oldFrame?.evaluate(element => element.isConnected)).toBe(false);
await expect(page.locator('iframe')).toHaveAttribute('src', /^http:\/\/localhost:4325\//);
await page.goBack();
await expect(page.getByLabel('Example UI')).toHaveValue('react');
await expect(frame.getByRole('status')).toHaveText('Ready.');
await expect(frame.locator('[data-native-message]')).toHaveCount(0);
await frame.getByLabel('Message', { exact: true }).fill('Unsubmitted draft');
await page.reload();
await expect(frame.getByRole('status')).toHaveText('Ready.');
await expect(frame.getByLabel('Message', { exact: true })).toHaveValue('');
expect(await records(request)).toHaveLength(1);
await page.goto('/docs/ag-ui/guides/tool-views?frontend=react');
await expect(page.getByRole('heading', { name: 'React AG-UI tool views preview', exact: true })).toBeVisible();
await page.goto(route);
await expect(page.getByLabel('Example UI')).toHaveValue('angular');
await expect(page.getByRole('heading', { name: 'React AG-UI client tools preview', exact: true })).toHaveCount(0);
await page.locator('[data-workspace-desktop-navigation]').getByRole('button', { name: 'Code', exact: true }).click();
await expect(page.getByRole('complementary', { name: 'File tree' }).getByRole('button', { name: 'client-tools.component.ts', exact: true })).toBeVisible();
expect(await records(request)).toHaveLength(1);
});
});
Loading
Loading