diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc27f3480..5e46df99f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | @@ -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 @@ -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: diff --git a/apps/website/content/AGENTS.md.template b/apps/website/content/AGENTS.md.template index d23087a4e..7d1bf7fc4 100644 --- a/apps/website/content/AGENTS.md.template +++ b/apps/website/content/AGENTS.md.template @@ -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. diff --git a/apps/website/content/CLAUDE.md.template b/apps/website/content/CLAUDE.md.template index d23087a4e..7d1bf7fc4 100644 --- a/apps/website/content/CLAUDE.md.template +++ b/apps/website/content/CLAUDE.md.template @@ -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. diff --git a/apps/website/e2e/react-ag-ui-client-tools-preview.spec.ts b/apps/website/e2e/react-ag-ui-client-tools-preview.spec.ts new file mode 100644 index 000000000..f33a44a2f --- /dev/null +++ b/apps/website/e2e/react-ag-ui-client-tools-preview.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index 04d6911f7..087c54415 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -64,7 +64,7 @@ export const createWebsitePlaywrightConfig = ( ? [...modeIgnores] : [...new Set([...modeIgnores, ...fixtureDrivenSpecs])]; - return defineConfig({ + const config = defineConfig({ testDir: './e2e', testMatch: bfcacheRuntimeTest ? '**/custom-runtime-bfcache.spec.ts' @@ -183,6 +183,12 @@ export const createWebsitePlaywrightConfig = ( url: 'http://localhost:4322', reuseExistingServer, }, + { + command: 'npx nx run cockpit-ag-ui-client-tools-angular:serve:cockpit --port 4325', + cwd: '../..', + url: 'http://localhost:4325', + reuseExistingServer, + }, { command: 'npx nx run cockpit-ag-ui-json-render-angular:serve:cockpit --port 4323', cwd: '../..', @@ -318,6 +324,14 @@ export const createWebsitePlaywrightConfig = ( reuseExistingServer, timeout: 180_000, }, + { + command: 'node scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs --serve', + cwd: '../..', + url: 'http://127.0.0.1:4640', + // A reused listener cannot establish canonical worker ownership. + reuseExistingServer: false, + timeout: 180_000, + }, { command: 'npx nx run cockpit-ag-ui-json-render-python:smoke && node scripts/react-cockpit/serve.mjs ag-ui-json-render --no-parent', cwd: '../..', @@ -470,6 +484,15 @@ export const createWebsitePlaywrightConfig = ( ] : undefined, }); + if (environment['WEBSITE_E2E_REACT_TOPIC'] === 'ag-ui-client-tools') { + config.testMatch = '**/react-ag-ui-client-tools-preview.spec.ts'; + if (Array.isArray(config.webServer)) { + config.webServer = config.webServer.filter(server => + [localURL, 'http://localhost:4325', 'http://127.0.0.1:4640'].includes(server.url ?? '') + ); + } + } + return config; }; export default createWebsitePlaywrightConfig(); diff --git a/apps/website/project.json b/apps/website/project.json index 76dabe277..59250fa86 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -13,7 +13,9 @@ "cockpit-ag-ui-json-render-angular", "cockpit-ag-ui-subagents-angular", "cockpit-chat-threads-angular", - "cockpit-render-computed-functions-angular" + "cockpit-render-computed-functions-angular", + "cockpit-ag-ui-client-tools-react", + "cockpit-ag-ui-client-tools-angular" ], "tags": ["scope:website", "scope:website-e2e", "type:app"], "targets": { @@ -107,7 +109,8 @@ "cockpit-deep-agents-filesystem-react", "cockpit-deep-agents-subagents-react", "cockpit-deep-agents-skills-react", - "cockpit-deep-agents-memory-react" + "cockpit-deep-agents-memory-react", + "cockpit-ag-ui-client-tools-react" ] } ], diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md index 92876a11d..0fce928c3 100644 --- a/apps/website/public/AGENTS.md +++ b/apps/website/public/AGENTS.md @@ -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. diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md index 92876a11d..0fce928c3 100644 --- a/apps/website/public/CLAUDE.md +++ b/apps/website/public/CLAUDE.md @@ -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. diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx index 798f41a22..f1a9c08fa 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx @@ -516,3 +516,20 @@ describe('unified docs workspace route', () => { expect(String(metadata.alternates?.canonical)).not.toContain('mode'); }); }); + +it('selects native AG-UI Client Tools React docs and actual assets while retaining Angular docs', async () => { + const tree = await route('ag-ui', 'guides', 'client-tools'); + const workspace = findElement(tree, WebsiteWorkspace as ComponentType); + const slot = workspace?.props.reactDocsSlot; + expect(isValidElement(slot) && typeof slot.type === 'function' && slot.type.name).toBe('ReactAgUiClientToolsPreview'); + expect(isValidElement(slot) && slot.props.exampleCode?.assetPaths).toEqual(expect.arrayContaining([ + 'cockpit/ag-ui/client-tools/react/src/tools.ts', + 'cockpit/ag-ui/client-tools/react/src/application.ts', + 'cockpit/ag-ui/client-tools/python/src/graph.py', + 'cockpit/ag-ui/client-tools/python/prompts/client-tools.md', + ])); + const angular = findElement(workspace?.props.docsSlot, MdxRenderer as ComponentType); + expect(angular?.props.exampleCode?.assetPaths?.some(path => path.includes('/client-tools/angular/'))).toBe(true); + expect(findElement(slot, ReactStreamingPreview as ComponentType)).toBeNull(); + expect(findElement(slot, ReactClientToolsPreview as ComponentType)).toBeNull(); +}); diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index 62ce21d64..7e7a34a35 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -12,6 +12,7 @@ import { ReactTimeTravelPreview } from '../../../../../components/docs/ReactTime import { ReactDeploymentRuntimePreview } from '../../../../../components/docs/ReactDeploymentRuntimePreview'; import { ReactAgUiStreamingPreview } from '../../../../../components/docs/ReactAgUiStreamingPreview'; import { ReactAgUiInterruptsPreview } from '../../../../../components/docs/ReactAgUiInterruptsPreview'; +import { ReactAgUiClientToolsPreview } from '../../../../../components/docs/ReactAgUiClientToolsPreview'; import { ReactAgUiToolViewsPreview } from '../../../../../components/docs/ReactAgUiToolViewsPreview'; import { ReactAgUiJsonRenderPreview } from '../../../../../components/docs/ReactAgUiJsonRenderPreview'; import { ReactAgUiSubagentsPreview } from '../../../../../components/docs/ReactAgUiSubagentsPreview'; @@ -289,7 +290,7 @@ export default async function DocsPage({ params }: DocsRouteProps) { (library === 'langgraph' || (library === 'deep-agents' && section === 'capabilities' && ['planning', 'filesystem', 'subagents', 'skills', 'memory'].includes(slug)) || (library === 'render' && ((section === 'guides' && ['specs','state-store','repeat-loops','registry'].includes(slug)) || (section === 'api' && ['render-spec-component','provide-render'].includes(slug)))) || - (library === 'ag-ui' && ((section === 'reference' && slug === 'event-mapping') || (section === 'guides' && ['interrupts','tool-views','json-render','subagents'].includes(slug)))) || + (library === 'ag-ui' && ((section === 'reference' && slug === 'event-mapping') || (section === 'guides' && ['interrupts','tool-views','client-tools','json-render','subagents'].includes(slug)))) || (library === 'chat' && ((section === 'guides' && ['thread-routing', 'generative-ui'].includes(slug)) || slug === 'client-tools' || (section === 'concepts' && slug === 'message-model') || (section === 'components' && ['chat-trace', 'chat-input', 'chat-interrupt-panel', 'chat-tool-calls', 'chat-subagent-card'].includes(slug))))) ? ( library === 'deep-agents' && section === 'capabilities' && slug === 'memory' ? ( @@ -309,6 +310,7 @@ export default async function DocsPage({ params }: DocsRouteProps) { slug === 'state-store' ? : ) : library === 'ag-ui' ? ( + slug === 'client-tools' ? : slug === 'subagents' ? : slug === 'json-render' ? : slug === 'tool-views' ? : diff --git a/apps/website/src/components/docs/ReactAgUiClientToolsPreview.tsx b/apps/website/src/components/docs/ReactAgUiClientToolsPreview.tsx new file mode 100644 index 000000000..39aa5165a --- /dev/null +++ b/apps/website/src/components/docs/ReactAgUiClientToolsPreview.tsx @@ -0,0 +1,73 @@ +import type { ExampleCodeContext } from '../../lib/example-code'; +import { MdxRenderer } from './MdxRenderer'; +const source = `# React AG-UI client tools preview + +Five browser tools demonstrate simulated weather, a cancellable status check, and an explicit fictional booking decision. This experimental source-build preview uses private React, core, content, and framework-neutral AG-UI candidate packages. These native APIs are not published npm packages. Angular continues to use the same canonical Python backend, stock model, and prompt. + +## Execute a typed browser catalog + +Choose **Run**. The five presets only fill the draft; **Send** submits your question. **get_weather** returns a fixed simulated reading. **slow_status_check** waits three seconds and supports **Stop**. **weather_card** renders a local reading and follows up. **weather_snapshot** renders the same reading without an automatic follow-up. **confirm_booking** waits for an explicit **Confirm** or **Cancel booking** choice. No tool fetches real weather or makes a real reservation. + + + + + +The native session owns the catalog and physical continuations. The app owns a local tool store, typed executors, and authored panels. Native tool observations retain literal call arguments and results; local execution records separately report browser work. A local result is not proof of server receipt: **unsent**, **sent-unconfirmed**, and **observed** describe delivery. A physical run can finish while the overall command still awaits browser execution, a continuation, or your decision. + + + + + +## Render weather and decide a fictional booking + +**weather_card** uses **followUp: true**. **weather_snapshot** uses **followUp: false**: its local result ends the command without an automatic final assistant response. The next explicit human question carries the result in native history without replaying the tool. Cards require a complete validated local reading and belong to the native assistant that requested them. + + + +A fictional booking resolves a browser promise through your explicit decision. This is separate from a native agent pause/resume. Once resolved, stopped, replaced, or unmounted, its controls cannot make another decision. + + + +## Stop and start fresh + +The first Send creates a UUID lazily. **New conversation** clears local state and draft; the next Send creates a fresh UUID. There is no automatic reconnect, replay, saved-history restoration, durable store, or promise of server persistence. Unknown calls, native pauses, errors, and uncertain outcomes conservatively require New. Validation failures may produce legitimate error ToolMessages and a native continuation; they are not manufactured cancellation results. + +**Stop** revokes active local controls and execution. The view can retain the last captured native observations for inspection. A last record marked running is historical evidence, not authority to continue, and a local completion or cancellation does not confirm native delivery. Leaving Run or changing frontend disposes the old session; returning starts empty and lazy. + +## Use the shared native runtime + +**Shared runtime** uses the existing **/ag-ui/client-tools/agent** route. Runtime settings can supply a Developer runtime; its configured address stays outside the visible preview. This React frontend uses **/ag-ui/client-tools/react/** and the AG-UI adapter. It shares the canonical backend with Angular. + + + + + + + + + +## Source build and offline verification + +From a checkout, with Node/npm, uv, and Python 3.12 available: + +~~~sh +npm ci +npx nx build cockpit-ag-ui-client-tools-react +npx nx e2e cockpit-ag-ui-client-tools-react +~~~ + +The e2e target depends on frozen canonical fixture preparation. For explicit preparation, run **npx nx run cockpit-ag-ui-client-tools-react:prepare-fixture**. The build installs the real private package artifacts with strict types. Deterministic offline browser checks use the actual canonical graph and locked AG-UI adapter with scripted model responses over HTTP/SSE. They verify local execution, physical continuations, and owned cleanup; they do not prove a live model-provider request. Docs, Code, and Run select this same source implementation. + + +`; +export function ReactAgUiClientToolsPreview({ exampleCode }: { + readonly exampleCode: ExampleCodeContext | null; +}) { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index 2c237c099..ccd541563 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -273,3 +273,19 @@ it('loads every bundled Skills Markdown asset as literal code for both frontends } } }); + +it('renders every native AG-UI Client Tools include from exact registered source bytes', async () => { + const page = await getWebsiteWorkspacePage({ docsPath: '/docs/ag-ui/guides/client-tools', title: 'Client Tools' }); + const react = page.frontendVariants?.react; + if (!react) throw new Error('Missing native AG-UI Client Tools variant'); + const context = getExampleCodeContext(react); + if (!context) throw new Error('Missing native AG-UI Client Tools source context'); + const article = readFileSync(join(WORKSPACE_ROOT, 'apps/website/src/components/docs/ReactAgUiClientToolsPreview.tsx'), 'utf8'); + const includes = includesIn(article); + expect(includes).toHaveLength(11); + for (const include of includes) { + const asset = resolveExampleFile(include.file, context); + expect(context.sources[asset]).toBe(readFileSync(join(WORKSPACE_ROOT, asset), 'utf8')); + expect(asset).not.toMatch(/fixture|scripted|\.spec\.|type-test/); + } +}); diff --git a/apps/website/src/playwright-config.spec.ts b/apps/website/src/playwright-config.spec.ts index 347f2b66d..7770f9c85 100644 --- a/apps/website/src/playwright-config.spec.ts +++ b/apps/website/src/playwright-config.spec.ts @@ -12,8 +12,8 @@ describe('Website Playwright configuration', () => { const prerequisites = project.targets.e2e.dependsOn?.find( (dependency: { target?: string }) => dependency.target === 'build' )?.projects ?? []; - expect(prerequisites).toHaveLength(33); - expect(new Set(prerequisites).size).toBe(33); + expect(prerequisites).toHaveLength(34); + expect(new Set(prerequisites).size).toBe(34); expect([...prerequisites].sort()).toEqual( getCockpitFrontends() .filter(frontend => frontend.frontend === 'react') @@ -59,19 +59,19 @@ describe('Website Playwright configuration', () => { const project = JSON.parse(readFileSync(resolve(import.meta.dirname,'../project.json'),'utf8')); expect(project.implicitDependencies).toContain('cockpit-ag-ui-tool-views-angular'); }); - it('starts thirty-three owned React servers after exact fresh Nx prerequisites', () => { + it('starts thirty-four owned React servers after exact fresh Nx prerequisites', () => { const config = createWebsitePlaywrightConfig({ CI: 'true' }); const servers = Array.isArray(config.webServer) ? config.webServer : []; - const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456789]|2[0123456789]|3[012])$/.test(server.url ?? '')); - expect(reactServers).toHaveLength(33); + const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456789]|2[0123456789]|3[012]|40)$/.test(server.url ?? '')); + expect(reactServers).toHaveLength(34); expect(reactServers.every(server => server.reuseExistingServer === false)).toBe(true); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4610')?.command).toBe('node scripts/react-cockpit/serve.mjs ag-ui-interrupts --no-parent'); const project = JSON.parse(readFileSync(resolve(import.meta.dirname, '../project.json'), 'utf8')); const projects = project.targets.e2e.dependsOn.find( (dependency: { target?: string }) => dependency.target === 'build' ).projects; - expect(projects).toHaveLength(33); - expect(new Set(projects).size).toBe(33); + expect(projects).toHaveLength(34); + expect(new Set(projects).size).toBe(34); expect(projects).toContain('cockpit-render-state-management-react'); expect(projects).toContain('cockpit-render-repeat-loops-react'); expect(projects).toContain('cockpit-render-registry-react'); @@ -298,6 +298,7 @@ describe('Website Playwright configuration', () => { command: 'npx nx run cockpit-ag-ui-tool-views-angular:serve:cockpit --port 4322', url: 'http://localhost:4322', }), + expect.objectContaining({command:'npx nx run cockpit-ag-ui-client-tools-angular:serve:cockpit --port 4325',url:'http://localhost:4325'}), expect.objectContaining({ command:'npx nx run cockpit-ag-ui-json-render-angular:serve:cockpit --port 4323', url:'http://localhost:4323', @@ -368,6 +369,7 @@ describe('Website Playwright configuration', () => { command: 'node scripts/react-cockpit/serve.mjs ag-ui-tool-views --no-parent', url: 'http://127.0.0.1:4611', }), + expect.objectContaining({command:'node scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs --serve',url:'http://127.0.0.1:4640'}), expect.objectContaining({command:'npx nx run cockpit-ag-ui-json-render-python:smoke && node scripts/react-cockpit/serve.mjs ag-ui-json-render --no-parent',url:'http://127.0.0.1:4612'}), expect.objectContaining({command:'npx nx run cockpit-ag-ui-subagents-python:smoke && node scripts/react-cockpit/serve.mjs ag-ui-subagents --no-parent',url:'http://127.0.0.1:4613'}), expect.objectContaining({command:'node scripts/react-cockpit/serve.mjs render-spec-rendering --no-parent',url:'http://127.0.0.1:4614'}), @@ -431,3 +433,22 @@ describe('Website Playwright configuration', () => { }); }); }); + +it('owns frozen AG-UI Client Tools preparation without reusing a foreign fixture', () => { + const config = createWebsitePlaywrightConfig({ PLAYWRIGHT_REUSE_EXISTING_SERVER: 'true' }); + const servers = Array.isArray(config.webServer) ? config.webServer : []; + expect(servers.find(server => server.url === 'http://127.0.0.1:4640')).toMatchObject({ + command: 'node scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs --serve', + timeout: 180_000, reuseExistingServer: false, + }); + const project = JSON.parse(readFileSync(resolve(import.meta.dirname, '../project.json'), 'utf8')); + expect(project.implicitDependencies).toContain('cockpit-ag-ui-client-tools-react'); + expect(project.targets.e2e.dependsOn.find((dependency: { target?: string }) => dependency.target === 'build').projects).toContain('cockpit-ag-ui-client-tools-react'); +}); + +it('scopes native client-tools checks to the Website and exact two owned frontend servers', () => { + const config = createWebsitePlaywrightConfig({ WEBSITE_E2E_REACT_TOPIC: 'ag-ui-client-tools', WEBSITE_E2E_PORT: '4390' }); + const servers = Array.isArray(config.webServer) ? config.webServer : []; + expect(servers.map(server => server.url)).toEqual(['http://127.0.0.1:4390', 'http://localhost:4325', 'http://127.0.0.1:4640']); + expect(config.testMatch).toBe('**/react-ag-ui-client-tools-preview.spec.ts'); +}); diff --git a/cockpit/ag-ui/client-tools/react/README.md b/cockpit/ag-ui/client-tools/react/README.md new file mode 100644 index 000000000..f28bed5e1 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/README.md @@ -0,0 +1,59 @@ +# React AG-UI client tools preview + +Five browser tools demonstrate simulated weather, a cancellable status check, and an explicit fictional booking decision. This experimental source-build preview uses private React, core, content, and framework-neutral AG-UI candidate packages. These native APIs are not published npm packages. Angular continues to use the same canonical Python backend, stock model, and prompt. + +## Execute a typed browser catalog + +Choose **Run**. The five presets only fill the draft; **Send** submits your question. **get_weather** returns a fixed simulated reading. **slow_status_check** waits three seconds and supports **Stop**. **weather_card** renders a local reading and follows up. **weather_snapshot** renders the same reading without an automatic follow-up. **confirm_booking** waits for an explicit **Confirm** or **Cancel booking** choice. No tool fetches real weather or makes a real reservation. + +Source: `cockpit/ag-ui/client-tools/react/src/tools.ts` + +Source: `cockpit/ag-ui/client-tools/react/src/app.tsx` + +The native session owns the catalog and physical continuations. The app owns a local tool store, typed executors, and authored panels. Native tool observations retain literal call arguments and results; local execution records separately report browser work. A local result is not proof of server receipt: **unsent**, **sent-unconfirmed**, and **observed** describe delivery. A physical run can finish while the overall command still awaits browser execution, a continuation, or your decision. + +Source: `cockpit/ag-ui/client-tools/react/src/application.ts` + +Source: `cockpit/ag-ui/client-tools/react/src/projection.ts` + +## Render weather and decide a fictional booking + +**weather_card** uses **followUp: true**. **weather_snapshot** uses **followUp: false**: its local result ends the command without an automatic final assistant response. The next explicit human question carries the result in native history without replaying the tool. Cards require a complete validated local reading and belong to the native assistant that requested them. + +Source: `cockpit/ag-ui/client-tools/react/src/weather-card.tsx` + +A fictional booking resolves a browser promise through your explicit decision. This is separate from a native agent pause/resume. Once resolved, stopped, replaced, or unmounted, its controls cannot make another decision. + +Source: `cockpit/ag-ui/client-tools/react/src/confirm-booking.tsx` + +## Stop and start fresh + +The first Send creates a UUID lazily. **New conversation** clears local state and draft; the next Send creates a fresh UUID. There is no automatic reconnect, replay, saved-history restoration, durable store, or promise of server persistence. Unknown calls, native pauses, errors, and uncertain outcomes conservatively require New. Validation failures may produce legitimate error ToolMessages and a native continuation; they are not manufactured cancellation results. + +**Stop** revokes active local controls and execution. The view can retain the last captured native observations for inspection. A last record marked running is historical evidence, not authority to continue, and a local completion or cancellation does not confirm native delivery. Leaving Run or changing frontend disposes the old session; returning starts empty and lazy. + +## Use the shared native runtime + +**Shared runtime** uses the existing **/ag-ui/client-tools/agent** route. Runtime settings can supply a Developer runtime; its configured address stays outside the visible preview. This React frontend uses **/ag-ui/client-tools/react/** and the AG-UI adapter. It shares the canonical backend with Angular. + +Source: `cockpit/ag-ui/client-tools/react/src/connection.ts` + +Source: `cockpit/ag-ui/client-tools/python/src/graph.py` + +Source: `cockpit/ag-ui/client-tools/python/src/server.py` + +Source: `cockpit/ag-ui/client-tools/python/prompts/client-tools.md` + +## Source build and offline verification + +From a checkout, with Node/npm, uv, and Python 3.12 available: + +~~~sh +npm ci +npx nx build cockpit-ag-ui-client-tools-react +npx nx e2e cockpit-ag-ui-client-tools-react +~~~ + +The e2e target depends on frozen canonical fixture preparation. For explicit preparation, run **npx nx run cockpit-ag-ui-client-tools-react:prepare-fixture**. The build installs the real private package artifacts with strict types. Deterministic offline browser checks use the actual canonical graph and locked AG-UI adapter with scripted model responses over HTTP/SSE. They verify local execution, physical continuations, and owned cleanup; they do not prove a live model-provider request. Docs, Code, and Run select this same source implementation. + +Source: `cockpit/ag-ui/client-tools/react/project.json` diff --git a/cockpit/ag-ui/client-tools/react/e2e/.gitignore b/cockpit/ag-ui/client-tools/react/e2e/.gitignore new file mode 100644 index 000000000..059a55910 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/e2e/.gitignore @@ -0,0 +1,3 @@ +test-results/ +playwright-report/ +*.tmp diff --git a/cockpit/ag-ui/client-tools/react/e2e/client-tools.spec.ts b/cockpit/ag-ui/client-tools/react/e2e/client-tools.spec.ts new file mode 100644 index 000000000..e97327fe2 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/e2e/client-tools.spec.ts @@ -0,0 +1,226 @@ +import { writeFile } from 'node:fs/promises'; +import { test, expect, type Page, type APIRequestContext } from '@playwright/test'; +interface WireMessage { id: string; role: string; content: string; toolCallId?: string; toolCalls?: {id: string}[] } +interface ModelMessage { id?: string; type: string; content: string; tool_call_id?: string } +interface WireRecord { + body: {threadId: string; runId: string; messages: WireMessage[]; tools: {name: string}[]}; + events: {type: string; messages?: WireMessage[]; delta?: string}[]; + observations: {socketAttempts: string[]; graphIdentity: number; checkpointerIdentity: number; modelIdentity: number; modelMessages: ModelMessage[][]}; + held: boolean; closed: boolean; +} +const base = '/ag-ui/client-tools/react/'; +const names = ['get_weather', 'slow_status_check', 'weather_card', 'weather_snapshot', 'confirm_booking']; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +async function records(request: APIRequestContext) { return (await request.get('/__requests')).json() as Promise; } +async function send(page: Page, text: string) { + await page.getByLabel('Message', {exact:true}).fill(text); + await page.getByRole('button', {name:'Send', exact:true}).click(); +} +async function complete(page: Page) { await expect(page.getByRole('status')).toHaveText('Command complete.'); } +function causal(wire: WireRecord[], count: number) { + expect(wire).toHaveLength(count); + expect(wire[0].body.messages).toHaveLength(1); + expect(wire[0].body.messages[0].role).toBe('user'); + expect(new Set(wire.map(row => row.body.threadId)).size).toBe(1); + expect(new Set(wire.map(row => row.body.runId)).size).toBe(count); + for (const row of wire) { + expect(row.body.runId).toMatch(uuid); + expect(row.body.threadId).toMatch(uuid); + expect(row.body.tools.map((tool: {name: string}) => tool.name)).toEqual(names); + expect(row.observations.socketAttempts).toEqual([]); + expect(row.observations.graphIdentity).toBe(wire[0].observations.graphIdentity); + expect(row.observations.checkpointerIdentity).toBe(wire[0].observations.checkpointerIdentity); + expect(row.observations.modelIdentity).toBe(wire[0].observations.modelIdentity); + 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')) { + const caller = row.body.messages.find(message => message.toolCalls?.some(call => call.id === result.toolCallId)); + expect(caller).toBeDefined(); + expect(row.body.messages.filter(message => message.id === result.id)).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.toolCallId === result.toolCallId && message.content === result.content)).toHaveLength(1); + } + } +} +test.beforeEach(async ({request}) => { expect((await request.post('/__reset')).ok()).toBe(true); }); +test.afterEach(async ({request}, info) => { + const stats = await (await request.post('/__finish')).json(); + expect(Object.values(stats.closed.at(-1).closure).every(Boolean)).toBe(true); + const receipt = info.outputPath('canonical-owned-fixture-receipt.json'); + await writeFile(receipt, JSON.stringify(stats, null, 2)); + await info.attach('canonical-owned-fixture-receipt', {path: receipt, contentType:'application/json'}); +}); +test('five presets fill only the draft and do not issue physical requests', async ({page,request}) => { + await page.goto(base); + for (const label of ['Weather result','Slow status','Weather card','Terminal snapshot','Fictional booking']) { + await page.getByRole('button', {name:label, exact:true}).click(); + await expect(page.getByLabel('Message', {exact:true})).not.toHaveValue(''); + expect(await records(request)).toEqual([]); + } +}); +for (const tool of ['get_weather','weather_card']) { + test(`${tool} produces one local receipt and one fresh physical continuation`, async ({page,request}) => { + await page.goto(base); await send(page, `Use ${tool}`); await complete(page); + const wire = await records(request); causal(wire, 2); + const result = wire[1].body.messages.filter((message:WireMessage) => message.role === 'tool'); + expect(result).toHaveLength(1); + expect(wire[1].observations.modelMessages.at(-1)?.filter((message:ModelMessage) => message.type === 'tool')).toHaveLength(1); + expect(wire[1].body.tools).toEqual(wire[0].body.tools); + expect(result[0].content).toBe(tool === 'get_weather' ? JSON.stringify({location:'San Francisco',temperatureF:68,conditions:'Sunny',humidity:55,windMph:8}) : JSON.stringify({displayed:true})); + await expect(page.locator('[data-native-call]')).toHaveCount(1); + const owner = wire[1].body.messages.find(message => message.toolCalls?.some(call => call.id === result[0].toolCallId)); + if (!owner) throw new Error('Missing canonical assistant call owner'); + await expect(page.locator(`[data-native-message="${owner.id}"] [data-native-call="${result[0].toolCallId}"]`)).toHaveCount(1); + await expect(page.getByRole('region', {name:'Local tool evidence'})).toContainText('Delivery: observed'); + await expect(page.getByRole('region',{name:'Conversation'})).toContainText('Canonical local results received'); + if (tool === 'weather_card') { + const card = page.getByRole('region',{name:'Simulated weather for San Francisco'}); + for (const text of ['68°F','Sunny','55%','8 mph']) await expect(card).toContainText(text); + await card.screenshot({path: test.info().outputPath('weather-desktop.png')}); + } + }); +} +test('terminal snapshot stays local and next explicit human retains it without replay', async ({page,request}) => { + await page.goto(base); await send(page,'Use weather_snapshot'); await complete(page); + causal(await records(request),1); + await expect(page.getByRole('region',{name:'Local tool evidence'})).toContainText('Delivery: unsent'); + await expect(page.locator('[data-native-message]')).toHaveCount(2); + await send(page,'Plain next human'); await complete(page); + const wire = await records(request); causal(wire,2); + expect(wire[1].body.messages.filter((message:WireMessage)=>message.role === 'tool')).toHaveLength(1); + expect(wire[1].body.messages.at(-1)?.content).toBe('Plain next human'); + await expect(page.locator('[data-native-call]')).toHaveCount(1); +}); +for (const decision of [true,false]) { + test(`fictional booking requires explicit ${decision} and continues once`, async ({page,request}) => { + await page.goto(base); await send(page,'Use confirm_booking'); + const panel = page.getByRole('region',{name:'Fictional booking'}); + await expect(panel).toContainText('No real reservation'); causal(await records(request),1); + const choice = page.getByRole('button',{name:decision?'Confirm':'Cancel booking',exact:true}); + const oldChoice = await choice.elementHandle(); + if (!oldChoice) throw new Error('Missing authored booking control'); + await choice.click(); await complete(page); + await oldChoice.evaluate((button:HTMLElement)=>button.click()); + const wire = await records(request); causal(wire,2); + expect(wire[1].body.messages.filter((message:WireMessage)=>message.role==='tool').map((message:WireMessage)=>JSON.parse(message.content))).toEqual([{confirmed:decision}]); + await expect(panel).toContainText(decision?'You confirmed':'You cancelled'); + await panel.screenshot({path: test.info().outputPath('fictional-booking-desktop.png')}); + await page.getByRole('button',{name:'New conversation',exact:true}).click(); + await oldChoice.evaluate((button:HTMLElement)=>button.click()); + expect(await records(request)).toHaveLength(2); + await send(page,'Plain fresh human'); await complete(page); + const after = await records(request); + expect(after[2].body.threadId).not.toBe(wire[0].body.threadId); + expect(after[2].body.messages).toHaveLength(1); + }); +} +test('slow status Stop cancels three-second local work without continuation or late completion', async ({page,request}) => { + await page.goto(base); await send(page,'Use slow_status_check'); + await expect(page.getByRole('region',{name:'Local tool evidence'})).toContainText('Local execution: running'); + await expect(page.getByRole('button',{name:'New conversation',exact:true})).toBeDisabled(); + await page.getByRole('button',{name:'Send',exact:true}).evaluate((button:HTMLButtonElement)=>button.click()); + await page.getByRole('button',{name:'Stop',exact:true}).click(); + await expect(page.getByRole('status')).toHaveText('Start a new conversation to continue.'); + await page.waitForTimeout(3200); causal(await records(request),1); + await expect(page.locator('main')).not.toContainText('"status":"complete"'); +}); +test('plain text is an actual canonical answer without tools', async ({page,request}) => { + await page.goto(base); await send(page,'Hello canonical'); await complete(page); causal(await records(request),1); + await expect(page.getByRole('region',{name:'Conversation'})).toContainText('Canonical plain answer: Hello canonical'); + await expect(page.locator('[data-native-call]')).toHaveCount(0); +}); +for (const text of ['Unknown','Malformed']) { + test(`${text} canonical calls never project a local weather or booking panel`, async ({page,request}) => { + await page.goto(base); await send(page,text); + await expect(page.getByRole('status')).toHaveText('Start a new conversation to continue.'); + causal(await records(request),text === 'Malformed' ? 2 : 1); + await expect(page.locator('.weather-card, .booking-panel')).toHaveCount(0); + }); +} +for (const action of ['release','Stop','page exit']) { + test(`actual streamed arguments held before terminal: ${action} respects execution boundary`, async ({page,request}) => { + await request.post('/__reset',{data:{mode:'stream',holdTerminal:true}}); + await page.goto(base); await send(page,'Use weather_card'); + await expect.poll(async()=> (await records(request))[0]?.held).toBe(true); + await expect(page.locator('.weather-card, .booking-panel')).toHaveCount(0); + await expect(page.getByRole('button',{name:'Send',exact:true})).toBeDisabled(); + const wire = await records(request); + expect(wire[0].events.filter((event:{type:string})=>event.type==='TOOL_CALL_ARGS').length).toBeGreaterThan(1); + if (action==='Stop') await page.getByRole('button',{name:'Stop',exact:true}).click(); + if (action==='page exit') await page.goto('/'); + if (action!=='release') await expect.poll(async()=> (await records(request))[0].closed).toBe(true); + await request.post('/__release'); + if (action==='release') { + // Continuation is held independently by the same owned canonical server. + await expect.poll(async()=> (await records(request))[1]?.held).toBe(true); + await request.post('/__release'); await complete(page); causal(await records(request),2); + } else { + causal(await records(request),1); + if (action==='Stop') await expect(page.getByRole('status')).toHaveText('Start a new conversation to continue.'); + } + }); +} +test('multiple native groups keep distinct causal identities on retained history', async ({page,request}) => { + await page.goto(base); await send(page,'Mixed'); await complete(page); + await send(page,'Use weather_card'); await complete(page); + const wire=await records(request); causal(wire,4); + await expect(page.locator('[data-native-call]')).toHaveCount(3); + const ids=wire[3].body.messages.flatMap((message:WireMessage)=>message.toolCalls?.map((call:{id:string})=>call.id)??[]); + expect(new Set(ids).size).toBe(3); + expect(wire[3].observations.modelMessages.at(-1)?.filter((message:ModelMessage)=>message.type==='tool')).toHaveLength(3); +}); +test('mobile literal content and weather readings remain safe', async ({page,request}) => { + await page.setViewportSize({width:390,height:844}); await page.goto(base); + await send(page,'Literal weather_card'); await complete(page); causal(await records(request),2); + await expect(page.locator('.weather-card')).toContainText(''); + await expect(page.locator('main script, main img')).toHaveCount(0); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + await page.locator('.weather-card').screenshot({path:test.info().outputPath('weather-mobile.png')}); + await page.locator('.weather-card').scrollIntoViewIfNeeded(); + await page.screenshot({path:test.info().outputPath('client-tools-mobile.png'),fullPage:true}); +}); +test('slow status naturally completes once and delivers its actual local result', async ({page,request}) => { + await page.goto(base); await send(page,'Use slow_status_check'); + await expect(page.getByRole('region',{name:'Local tool evidence'})).toContainText('Local execution: running'); + await complete(page); const wire=await records(request); causal(wire,2); + expect(wire[1].body.messages.filter((message:WireMessage)=>message.role==='tool').map((message:WireMessage)=>JSON.parse(message.content))).toEqual([{label:'demo check',status:'complete'}]); +}); +test('one explicit human runs two actual client-tool groups and final canonical receipt', async ({page,request}) => { + await page.goto(base); await send(page,'Rounds get_weather'); await complete(page); + const wire=await records(request); causal(wire,3); + expect(wire[2].body.messages.filter(message=>message.role==='user')).toHaveLength(1); + expect(wire[2].body.messages.filter(message=>message.role==='tool')).toHaveLength(2); + await expect(page.locator('[data-native-call]')).toHaveCount(2); + expect(new Set(wire[2].body.messages.flatMap(message=>message.toolCalls?.map(call=>call.id)??[])).size).toBe(2); +}); +for(const action of ['Stop','page exit']) { + test(`pending booking ${action} revokes controls without result or physical continuation`,async({page,request})=>{ + await page.goto(base); await send(page,'Use confirm_booking'); + await expect(page.getByRole('region',{name:'Fictional booking'})).toContainText('No real reservation'); + const control=await page.getByRole('button',{name:'Confirm',exact:true}).elementHandle(); + if(!control) throw new Error('Missing pending booking control'); + if(action==='Stop') { + await page.getByRole('button',{name:'Stop',exact:true}).click(); + await control.evaluate((button:HTMLElement)=>button.click()); + await expect(page.getByRole('status')).toHaveText('Start a new conversation to continue.'); + await expect(page.getByRole('region',{name:'Fictional booking'})).toContainText('Booking decision cancelled.'); + } else await page.goto('/'); + const wire=await records(request); causal(wire,1); + expect(wire[0].body.messages.filter(message=>message.role==='tool')).toEqual([]); + }); +} +test('Stop during actual held continuation preserves complete sent-unconfirmed local evidence without replay',async({page,request})=>{ + await request.post('/__reset',{data:{holdTerminal:true,holdAfterRequest:1,holdReceipt:true}}); + await page.goto(base); await send(page,'Use get_weather'); + await expect.poll(async()=> (await records(request))[1]?.held).toBe(true); + await expect(page.getByRole('region',{name:'Local tool evidence'})).toContainText('Local execution: complete'); + await expect(page.getByRole('region',{name:'Local tool evidence'})).toContainText('Delivery: sent-unconfirmed'); + await page.getByRole('button',{name:'Stop',exact:true}).click(); + await expect.poll(async()=> (await records(request))[1].closed).toBe(true); + await request.post('/__release'); + await expect(page.getByRole('status')).toHaveText('Start a new conversation to continue.'); + causal(await records(request),2); + await expect(page.getByRole('region',{name:'Local tool evidence'})).toContainText('Delivery: sent-unconfirmed'); + await page.getByRole('button',{name:'New conversation',exact:true}).click(); + expect(await records(request)).toHaveLength(2); +}); diff --git a/cockpit/ag-ui/client-tools/react/e2e/playwright.config.ts b/cockpit/ag-ui/client-tools/react/e2e/playwright.config.ts new file mode 100644 index 000000000..a15ad2115 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/e2e/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: '.', outputDir: './test-results', workers: 1, fullyParallel: false, timeout: 30000, + use: { baseURL: 'http://127.0.0.1:4640', trace: 'retain-on-failure' }, + projects: [{name:'chromium', use: {...devices['Desktop Chrome']}}], + webServer: { + command: 'node scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs --serve', + cwd: '../../../../..', + url: 'http://127.0.0.1:4640', reuseExistingServer: false, timeout: 180000, + }, +}); diff --git a/cockpit/ag-ui/client-tools/react/e2e/tsconfig.json b/cockpit/ag-ui/client-tools/react/e2e/tsconfig.json new file mode 100644 index 000000000..d83f57454 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/e2e/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { "strict": true, "types": ["node"] }, + "include": ["*.ts"] +} diff --git a/cockpit/ag-ui/client-tools/react/index.html b/cockpit/ag-ui/client-tools/react/index.html new file mode 100644 index 000000000..2ed561e91 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/index.html @@ -0,0 +1,12 @@ + + + + + + AG-UI client tools · React + + +
+ + + diff --git a/cockpit/ag-ui/client-tools/react/project.json b/cockpit/ag-ui/client-tools/react/project.json new file mode 100644 index 000000000..6dbbc4351 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-ag-ui-client-tools-react", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/ag-ui/client-tools/react/src", + "projectType": "application", + "tags": [ + "scope:cockpit-examples", + "scope:cockpit-e2e", + "framework:react" + ], + "targets": { + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "cockpit/ag-ui/client-tools/react/vite.config.mts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "build": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { + "target": "build", + "projects": [ + "core", + "content", + "react" + ] + } + ], + "outputs": [ + "{workspaceRoot}/dist/cockpit/ag-ui/client-tools/react" + ], + "options": { + "command": "node scripts/react-cockpit/build.mjs ag-ui-client-tools" + } + }, + "prepare-fixture": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "command": "node scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs" + } + }, + "e2e": { + "executor": "@nx/playwright:playwright", + "cache": false, + "dependsOn": [ + "prepare-fixture", + "build" + ], + "options": { + "config": "cockpit/ag-ui/client-tools/react/e2e/playwright.config.ts", + "skipInstall": true + } + } + } +} diff --git a/cockpit/ag-ui/client-tools/react/src/app.spec.tsx b/cockpit/ag-ui/client-tools/react/src/app.spec.tsx new file mode 100644 index 000000000..e9c9f8c67 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/app.spec.tsx @@ -0,0 +1,285 @@ +import { createSession } from '@threadplane/ag-ui'; +import type { RunAgentInput } from '@ag-ui/client'; +import { createClientToolsApplication } from './application'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + act, +} from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { ClientToolsDemo } from './app'; +import type { ClientToolsApplicationSnapshot } from './application'; +const mocks = vi.hoisted(() => ({ create: vi.fn() })); +vi.mock('./connection', () => ({ createConnectedApplication: mocks.create })); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); +function fixture(update: Partial = {}) { + let snapshot: ClientToolsApplicationSnapshot = Object.freeze({ + threadId: null, + native: undefined, + views: [], + bookings: [], + busy: false, + canSubmit: true, + activity: 'idle', + outcome: null, + error: null, + viewGeneration: 0, + ...update, + }); + const listeners = new Set<() => void>(); + const app = { + getSnapshot: () => snapshot, + subscribe: (notify: () => void) => { + listeners.add(notify); + return () => { + listeners.delete(notify); + }; + }, + submit: vi.fn(), + stop: vi.fn(), + newConversation: vi.fn(), + respond: vi.fn(), + dispose: vi.fn(), + }; + mocks.create.mockReturnValue(app); + const ready = vi.fn(); + const result = render( + + ); + return { + app, + ready, + result, + update(value: Partial) { + act(() => { + snapshot = Object.freeze({ ...snapshot, ...value }); + for (const notify of listeners) notify(); + }); + }, + }; +} +it('mount and readiness are inert and five presets only fill the draft', () => { + const f = fixture(); + expect(f.ready).toHaveBeenCalledOnce(); + expect(f.app.submit).not.toHaveBeenCalled(); + expect(screen.queryByText('PRIVATE_ENDPOINT')).toBeNull(); + const presets = screen.getByRole('group', { name: 'Example prompts' }); + expect(presets.querySelectorAll('button')).toHaveLength(5); + fireEvent.click(screen.getByRole('button', { name: 'Terminal snapshot' })); + expect( + (screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement) + .value + ).toContain('snapshot'); + expect(f.app.submit).not.toHaveBeenCalled(); +}); +it('New clears draft and unmount revokes application lifetime', () => { + const f = fixture(); + fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), { + target: { value: 'draft' }, + }); + f.update({ viewGeneration: 1 }); + expect( + (screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement) + .value + ).toBe(''); + f.result.unmount(); + expect(f.app.dispose).toHaveBeenCalledOnce(); +}); +it('renders native text and observation literally and separate local evidence safely', () => { + const text = ' **literal**'; + fixture({ + views: [ + { + id: 'a', + text: { id: 'a', role: 'assistant', content: text }, + tools: [ + { + id: 'c', + name: 'weather_snapshot', + argumentsText: text, + sourceRunId: 'r', + weather: { + location: text, + conditions: text, + temperatureF: 68, + humidity: 55, + windMph: 8, + }, + local: { + name: 'weather_snapshot', + callId: 'c', + assistantMessageId: 'a', + sourceRunId: 'r', + rawArguments: text, + status: 'complete', + delivery: 'unsent', + provenance: 'local', + }, + }, + ], + }, + ], + }); + expect(document.querySelector('img')).toBeNull(); + expect( + document.querySelector('[data-native-call="c"]')?.textContent + ).toContain('Local execution: complete'); + expect(screen.getByText('Delivery: unsent')).toBeTruthy(); + expect(screen.getByText('Simulated weather')).toBeTruthy(); +}); +it('pending fictional booking dispatches exact token only, resolved and aborted controls are frozen', () => { + const booking = { + ownerGeneration: 1, + callId: 'c', + assistantMessageId: 'a', + sourceRunId: 'r', + summary: 'Fictional table', + token: 'owned', + status: 'pending' as const, + }; + const f = fixture({ + busy: true, + canSubmit: false, + bookings: [booking], + views: [ + { + id: 'a', + tools: [ + { + id: 'c', + name: 'confirm_booking', + argumentsText: '{"summary":"Fictional table"}', + sourceRunId: 'r', + local: { + name: 'confirm_booking', + callId: 'c', + assistantMessageId: 'a', + sourceRunId: 'r', + rawArguments: '{}', + status: 'running', + delivery: 'unsent', + provenance: 'local', + }, + }, + ], + }, + ], + }); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + expect(f.app.respond).toHaveBeenCalledWith('owned', true); + fireEvent.click(screen.getByRole('button', { name: 'Cancel booking' })); + expect(f.app.respond).toHaveBeenCalledWith('owned', false); + f.update({ bookings: [{ ...booking, status: 'aborted' }] }); + expect(screen.queryByRole('button', { name: 'Confirm' })).toBeNull(); + expect(screen.getByText('Booking decision cancelled.')).toBeTruthy(); + f.update({ + bookings: [{ ...booking, status: 'resolved', confirmed: false }], + }); + expect( + screen.getByText('You cancelled this fictional booking.') + ).toBeTruthy(); +}); +it('busy input exposes Stop and prevents New, while blocked idle asks for explicit New', () => { + const f = fixture({ busy: true, canSubmit: false }); + expect( + ( + screen.getByRole('button', { + name: 'New conversation', + }) as HTMLButtonElement + ).disabled + ).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + expect(f.app.stop).toHaveBeenCalledOnce(); + f.update({ + busy: false, + error: 'Start a new conversation.', + outcome: 'interrupted', + }); + expect(screen.getByRole('alert').textContent).toBe( + 'Start a new conversation.' + ); +}); + +it('actual owned pending booking loses all authority on React unmount without native continuation (synthetic SDK seam)', async () => { + let stream!: ReadableStreamDefaultController; + let body: RunAgentInput | undefined; + const fetch = vi.fn( + async (_url: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)) as RunAgentInput; + return new Response( + new ReadableStream({ + start(value) { + stream = value; + }, + }), + { headers: { 'content-type': 'text/event-stream' } } + ); + } + ); + const app = createClientToolsApplication({ + sessionFactory(threadId, tools) { + return createSession( + { threadId, url: 'http://synthetic.invalid/unmount', fetch }, + tools + ); + }, + }); + mocks.create.mockReturnValue(app); + const rendered = render( + undefined} + /> + ); + expect(fetch).not.toHaveBeenCalled(); + let done = Promise.resolve(); + act(() => { + done = app.submit('Fictional booking'); + }); + await waitFor(() => expect(body).toBeDefined()); + if (!body) throw new Error('Missing synthetic request'); + for (const event of [ + { type: 'RUN_STARTED', threadId: body.threadId, runId: body.runId }, + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + ...body.messages, + { + id: 'unmount-assistant', + role: 'assistant', + toolCalls: [ + { + id: 'unmount-call', + type: 'function', + function: { + name: 'confirm_booking', + arguments: '{"summary":"Fictional only"}', + }, + }, + ], + }, + ], + }, + { type: 'RUN_FINISHED', threadId: body.threadId, runId: body.runId }, + ]) + stream.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) + ); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Confirm' })).toBeTruthy() + ); + const token = app.getSnapshot().bookings[0].token; + rendered.unmount(); + await done; + expect(app.respond(token, true)).toBe(false); + expect(fetch).toHaveBeenCalledOnce(); +}); diff --git a/cockpit/ag-ui/client-tools/react/src/app.tsx b/cockpit/ag-ui/client-tools/react/src/app.tsx new file mode 100644 index 000000000..240b3d89b --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/app.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react'; +import { useAgent } from '@threadplane/react'; +import { + ChatInput, + TextTranscript, + ToolObservation, +} from '@threadplane/react/chat'; +import { + createConnectedApplication, + type ClientToolsConnection, +} from './connection'; +import { WeatherCard } from './weather-card'; +import { ConfirmBooking } from './confirm-booking'; +const prompts = Object.freeze([ + { + label: 'Weather result', + text: 'Use get_weather for simulated weather in San Francisco.', + }, + { + label: 'Slow status', + text: 'Run slow_status_check with the label demo check.', + }, + { + label: 'Weather card', + text: 'Show simulated weather with weather_card for San Francisco.', + }, + { + label: 'Terminal snapshot', + text: 'Show simulated weather with weather_snapshot without a follow-up.', + }, + { + label: 'Fictional booking', + text: 'Ask me to confirm a fictional dinner booking with confirm_booking.', + }, +]); +export function ClientToolsDemo({ + connection, + onReady, +}: { + readonly connection: ClientToolsConnection; + readonly onReady: () => void; +}) { + const [application] = useState(() => createConnectedApplication(connection)); + const [draft, setDraft] = useState(''); + const snapshot = useAgent(application); + useEffect(() => { + onReady(); + }, [onReady]); + useEffect( + () => () => { + void application.dispose(); + }, + [application] + ); + useEffect(() => { + setDraft(''); + }, [snapshot.viewGeneration]); + const status = snapshot.busy + ? snapshot.activity === 'replacing' + ? 'Starting a new conversation…' + : snapshot.bookings.some((row) => row.status === 'pending') + ? 'Waiting for your fictional booking decision…' + : 'Running client tools…' + : !snapshot.canSubmit + ? 'Start a new conversation to continue.' + : snapshot.outcome === 'success' + ? 'Command complete.' + : 'Ready.'; + return ( +
+
+
+

React · Experimental

+

AG-UI client tools

+
+ +
+

+ Five browser tools demonstrate simulated weather, a cancellable status + check and an explicit fictional booking decision. +

+
+

Runtime connection

+

{connection.label}

+
+
+ {prompts.map((prompt) => ( + + ))} +
+
+
    + {snapshot.views.map((message) => ( +
  1. + {message.text && ( + + )} + {message.tools.map((tool) => ( +
    + + {tool.local && ( +
    +

    Local execution: {tool.local.status}

    +

    Delivery: {tool.local.delivery}

    +
    + )} + {tool.weather && } + {snapshot.bookings + .filter( + (booking) => + booking.assistantMessageId === message.id && + booking.callId === tool.id && + booking.sourceRunId === tool.sourceRunId && + tool.local?.name === 'confirm_booking' + ) + .map((booking) => ( + { + application.respond(token, confirmed); + }} + /> + ))} +
    + ))} +
  2. + ))} +
+
+ {snapshot.error &&

{snapshot.error}

} + void application.stop() : undefined} + onSubmit={(text) => { + if (!application.getSnapshot().canSubmit) return false; + void application.submit(text); + return application.getSnapshot().busy; + }} + /> +

{status}

+
+ ); +} diff --git a/cockpit/ag-ui/client-tools/react/src/application.spec.ts b/cockpit/ag-ui/client-tools/react/src/application.spec.ts new file mode 100644 index 000000000..0b2967177 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/application.spec.ts @@ -0,0 +1,934 @@ +import { createServer, type ServerResponse } from 'node:http'; +import { once } from 'node:events'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ExecutionContext } from '@threadplane/core/tools'; +import type { BookingArguments } from './tools'; +import { createSession } from '@threadplane/ag-ui'; +import type { RunAgentInput } from '@ag-ui/client'; +import { + createClientToolsApplication, + type Tools, + type NativeSnapshot, +} from './application'; + +const disposals: (() => void)[] = []; +afterEach(() => { + for (const dispose of disposals.splice(0)) dispose(); + vi.restoreAllMocks(); +}); +async function until(check: () => boolean) { + for (let i = 0; i < 200; i++) { + if (check()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error('Synthetic native fixture milestone timeout'); +} +// Controlled SDK fetch/SSE seam, deliberately synthetic. Canonical provider proof belongs to B3. +function fixture( + transform?: (snapshot: NativeSnapshot) => NativeSnapshot, + onBooking?: () => void +) { + const requests: { + body: RunAgentInput; + send: (...events: unknown[]) => void; + end: () => void; + }[] = []; + const fetch = vi.fn( + async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as RunAgentInput; + let stream!: ReadableStreamDefaultController; + const response = new Response( + new ReadableStream({ + start(c) { + stream = c; + }, + }), + { headers: { 'content-type': 'text/event-stream' } } + ); + requests.push({ + body, + end() { + stream.close(); + }, + send(...events) { + for (const event of events) + stream.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) + ); + }, + }); + return response; + } + ); + const factory = vi.fn((threadId: string, tools: Tools) => { + const session = createSession( + { threadId, url: 'http://synthetic.invalid/agent', fetch }, + onBooking + ? { + ...tools, + confirm_booking: { + ...tools.confirm_booking, + handler(input: BookingArguments, context: ExecutionContext) { + onBooking(); + return tools.confirm_booking.handler(input, context); + }, + }, + } + : tools + ); + return transform + ? { ...session, getSnapshot: () => transform(session.getSnapshot()) } + : session; + }); + const app = createClientToolsApplication({ sessionFactory: factory }); + disposals.push(() => { + void app.dispose(); + }); + async function next(index = 0) { + await until(() => requests.length > index); + return requests[index]; + } + function finish(index: number, name?: string, args: unknown = {}) { + const request = requests[index]; + const { threadId, runId, messages } = request.body; + request.send( + { type: 'RUN_STARTED', threadId, runId }, + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + ...messages, + name + ? { + id: `assistant-${index}`, + role: 'assistant', + toolCalls: [ + { + id: `call-${index}`, + type: 'function', + function: { name, arguments: JSON.stringify(args) }, + }, + ], + } + : { + id: `answer-${index}`, + role: 'assistant', + content: 'Literal answer', + }, + ], + }, + { type: 'RUN_FINISHED', threadId, runId } + ); + } + return { app, factory, fetch, requests, next, finish }; +} +const weather = { + location: 'Paris', + temperatureF: 68, + conditions: 'Sunny', + humidity: 55, + windMph: 8, +}; +describe('application with actual native owner and synthetic SDK SSE', () => { + it('constructs and subscribes inertly, lazily creates exactly one typed catalog and UUID on explicit Send', async () => { + const uuid = vi.spyOn(crypto, 'randomUUID'); + const f = fixture(); + const notify = vi.fn(); + f.app.subscribe(notify); + expect(f.factory).not.toHaveBeenCalled(); + expect(f.fetch).not.toHaveBeenCalled(); + expect(f.app.getSnapshot().threadId).toBeNull(); + expect(uuid).not.toHaveBeenCalled(); + await f.app.submit(' '); + expect(f.factory).not.toHaveBeenCalled(); + const done = f.app.submit(' authored text '); + await f.next(); + expect(f.factory).toHaveBeenCalledTimes(1); + expect(f.factory.mock.calls[0][0]).toBe(uuid.mock.results[0].value); + expect(Object.keys(f.factory.mock.calls[0][1])).toHaveLength(5); + expect(f.requests[0].body.threadId).toMatch(/^[0-9a-f-]{36}$/); + expect(f.requests[0].body.messages[0]).toMatchObject({ + role: 'user', + content: ' authored text ', + }); + await f.app.submit('ignored busy'); + expect(f.requests).toHaveLength(1); + f.finish(0); + await done; + expect(f.app.getSnapshot()).toMatchObject({ + busy: false, + canSubmit: true, + outcome: 'success', + }); + }); + it.each([true, false])( + 'binds the actual running booking and sends exactly one native continuation after %s', + async (confirmed) => { + const f = fixture(); + const done = f.app.submit('Booking'); + await f.next(); + f.finish(0, 'confirm_booking', { summary: 'Fictional table' }); + await until(() => f.app.getSnapshot().bookings.length === 1); + const panel = f.app.getSnapshot().bookings[0]; + expect(panel).toMatchObject({ + callId: 'call-0', + assistantMessageId: 'assistant-0', + sourceRunId: f.requests[0].body.runId, + summary: 'Fictional table', + status: 'pending', + }); + expect(f.app.respond('wrong', confirmed)).toBe(false); + expect(f.requests).toHaveLength(1); + expect(f.app.respond(panel.token, confirmed)).toBe(true); + expect(f.app.respond(panel.token, !confirmed)).toBe(false); + await f.next(1); + expect(f.requests[1].body.threadId).toBe(f.requests[0].body.threadId); + expect(f.requests[1].body.runId).not.toBe(f.requests[0].body.runId); + expect( + f.requests[1].body.messages.filter((m) => m.role === 'user') + ).toHaveLength(1); + expect( + f.requests[1].body.messages.filter((m) => m.role === 'tool') + ).toEqual([ + expect.objectContaining({ + toolCallId: 'call-0', + content: JSON.stringify({ confirmed }), + }), + ]); + f.finish(1); + await done; + expect(f.app.getSnapshot().canSubmit).toBe(true); + } + ); + it('terminal snapshot succeeds with one POST and unsent local result retained in the next human request', async () => { + const f = fixture(); + const done = f.app.submit('Snapshot'); + await f.next(); + f.finish(0, 'weather_snapshot', weather); + await done; + expect(f.requests).toHaveLength(1); + expect(f.app.getSnapshot()).toMatchObject({ + canSubmit: true, + outcome: 'success', + }); + expect(f.app.getSnapshot().native?.clientTools.calls[0]).toMatchObject({ + status: 'complete', + delivery: 'unsent', + result: { displayed: true }, + }); + expect(f.app.getSnapshot().views[1].tools[0].weather).toEqual(weather); + const next = f.app.submit('Next human'); + await f.next(1); + expect( + f.requests[1].body.messages.filter((m) => m.role === 'tool') + ).toHaveLength(1); + expect( + f.requests[1].body.messages.filter((m) => m.role === 'user') + ).toHaveLength(2); + f.finish(1); + await next; + }); + it.each(['get_weather', 'weather_card'])( + 'projects completed local %s and leaves continuation entirely native', + async (name) => { + const f = fixture(); + const done = f.app.submit('Weather'); + await f.next(); + f.finish( + 0, + name, + name === 'get_weather' ? { location: 'Paris' } : weather + ); + await f.next(1); + f.finish(1); + await done; + expect(f.app.getSnapshot().views[1].tools[0].weather).toEqual(weather); + expect(f.app.getSnapshot().native?.clientTools.calls[0].delivery).toBe( + 'observed' + ); + expect(f.requests).toHaveLength(2); + } + ); + it('reentrant Stop during running publication revokes authority before native invokes booking', async () => { + const invoked = vi.fn(); + const f = fixture(undefined, invoked); + f.app.subscribe(() => { + if ( + f.app + .getSnapshot() + .native?.clientTools.calls.some((c) => c.status === 'running') + ) + void f.app.stop(); + }); + const done = f.app.submit('Booking'); + await f.next(); + f.finish(0, 'confirm_booking', { summary: 'Fictional' }); + await done; + expect(f.app.getSnapshot().bookings).toHaveLength(0); + expect(invoked).not.toHaveBeenCalled(); + expect(f.app.getSnapshot().canSubmit).toBe(false); + expect(f.requests).toHaveLength(1); + }); + it('Stop revokes pending controls, New clears ownership and old controls cannot affect the next session', async () => { + const f = fixture(); + const done = f.app.submit('Booking'); + await f.next(); + f.finish(0, 'confirm_booking', { summary: 'Fictional' }); + await until(() => f.app.getSnapshot().bookings.length > 0); + const old = f.app.getSnapshot().bookings[0]; + const thread = f.app.getSnapshot().threadId; + await f.app.stop(); + await done; + expect(f.app.respond(old.token, false)).toBe(false); + expect(f.app.getSnapshot().bookings[0]).toMatchObject({ + status: 'aborted', + }); + await f.app.newConversation(); + expect(f.app.getSnapshot()).toMatchObject({ + threadId: null, + native: undefined, + bookings: [], + views: [], + error: null, + canSubmit: true, + viewGeneration: 1, + }); + const next = f.app.submit('new'); + await f.next(1); + expect(f.app.getSnapshot().threadId).not.toBe(thread); + expect(f.app.respond(old.token, true)).toBe(false); + f.finish(1); + await next; + }); + it('Stop cancels a slow tool without stale result or native continuation', async () => { + const f = fixture(); + const done = f.app.submit('Slow'); + await f.next(); + f.finish(0, 'slow_status_check', {}); + await until( + () => + !!f.app + .getSnapshot() + .native?.clientTools.calls.some((c) => c.status === 'running') + ); + await f.app.stop(); + await done; + expect(f.requests).toHaveLength(1); + expect( + f.app.getSnapshot().native?.clientTools.calls[0].result + ).toBeUndefined(); + }); + it.each([ + ['unknown', {}], + ['weather_card', { location: 'partial' }], + ])('requires New after unsupported or invalid %s', async (name, args) => { + const f = fixture(); + const done = f.app.submit('negative'); + await f.next(); + f.finish(0, String(name), args); + if (name === 'weather_card') { + await f.next(1); + f.finish(1); + } + await done; + expect(f.app.getSnapshot().canSubmit).toBe(false); + await f.app.submit('no replay'); + expect(f.requests).toHaveLength(name === 'weather_card' ? 2 : 1); + }); + it('subscriber exceptions cannot strand command settlement or cleanup', async () => { + const f = fixture(); + f.app.subscribe(() => { + throw new Error('view error'); + }); + const done = f.app.submit('Plain'); + await f.next(); + f.finish(0); + await done; + expect(f.app.getSnapshot().busy).toBe(false); + await f.app.dispose(); + await f.app.dispose(); + }); + it('reentrant disposal during admission prevents factory and transport effects', async () => { + const f = fixture(); + f.app.subscribe(() => { + if (f.app.getSnapshot().busy) void f.app.dispose(); + }); + await f.app.submit('never'); + expect(f.factory).not.toHaveBeenCalled(); + expect(f.fetch).not.toHaveBeenCalled(); + }); +}); + +// Focused lifecycle seam negatives; these do not claim canonical provider proof. +it('throwing factory leaves a blocked idle app with no thread until explicit New', async () => { + const app = createClientToolsApplication({ + sessionFactory() { + throw new Error('SECRET factory'); + }, + }); + await app.submit('one'); + expect(app.getSnapshot()).toMatchObject({ + threadId: null, + busy: false, + canSubmit: false, + outcome: 'error', + }); + expect(app.getSnapshot().error).not.toContain('SECRET'); + await app.newConversation(); + expect(app.getSnapshot().canSubmit).toBe(true); + await app.dispose(); +}); +it('throwing native read, subscription and lifecycle never prevents observed cleanup', async () => { + const dispose = vi.fn(() => Promise.reject(new Error('cleanup'))); + const stop = vi.fn(() => { + throw new Error('stop'); + }); + const app = createClientToolsApplication({ + sessionFactory(threadId, tools) { + const native = createSession( + { threadId, url: 'http://synthetic.invalid/never' }, + tools + ); + return { + ...native, + subscribe() { + throw new Error('subscribe'); + }, + getSnapshot() { + throw new Error('read'); + }, + stop, + dispose, + }; + }, + }); + await app.submit('one'); + expect(app.getSnapshot()).toMatchObject({ + busy: false, + canSubmit: false, + outcome: 'error', + }); + expect(dispose).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + await app.dispose(); +}); +it('reentrant Stop in submit method access prevents invoking that revoked method', async () => { + const submit = vi.fn(async () => 'success' as const); + const app = createClientToolsApplication({ + sessionFactory(threadId, tools) { + const native = createSession( + { threadId, url: 'http://synthetic.invalid/never' }, + tools + ); + return { + ...native, + get submit() { + void app.stop(); + return submit; + }, + }; + }, + }); + await app.submit('one'); + expect(submit).not.toHaveBeenCalled(); + expect(app.getSnapshot().canSubmit).toBe(false); + await app.dispose(); +}); +it('disposal promptly observes noncooperative lifecycle rejection without awaiting it', async () => { + let reject!: (error: unknown) => void; + const hung = new Promise((_yes, no) => { + reject = no; + }); + const app = createClientToolsApplication({ + sessionFactory(threadId, tools) { + const native = createSession( + { threadId, url: 'http://synthetic.invalid/never' }, + tools + ); + return { + ...native, + submit: async () => 'interrupted', + stop: () => hung, + dispose: () => hung, + }; + }, + }); + await app.submit('one'); + await app.dispose(); + reject(new Error('late cleanup')); + await Promise.resolve(); +}); + +it.each(['ambiguous', 'wrong-run', 'missing', 'invalid-args'] as const)( + 'actual booking invocation rejects %s current native accessor evidence', + async (mode) => { + const f = fixture((native) => { + const running = native.clientTools.calls.find( + (call) => call.name === 'confirm_booking' && call.status === 'running' + ); + if (!running || running.name !== 'confirm_booking') return native; + const calls = + mode === 'ambiguous' + ? [...native.clientTools.calls, running] + : mode === 'missing' + ? [] + : [ + { + ...running, + ...(mode === 'wrong-run' + ? { sourceRunId: 'outsider-run' } + : { args: { summary: '' } }), + }, + ]; + return { ...native, clientTools: { ...native.clientTools, calls } }; + }); + const done = f.app.submit('Booking'); + await f.next(); + f.finish(0, 'confirm_booking', { summary: 'Fictional' }); + await f.next(1); + f.finish(1); + await done; + expect(f.app.getSnapshot().bookings).toHaveLength(0); + expect(f.app.getSnapshot().canSubmit).toBe(false); + } +); +it('native pause requires explicit New and application never resumes it', async () => { + const f = fixture(); + const done = f.app.submit('Pause'); + const first = await f.next(); + first.send( + { + type: 'RUN_STARTED', + threadId: first.body.threadId, + runId: first.body.runId, + }, + { + type: 'RUN_FINISHED', + threadId: first.body.threadId, + runId: first.body.runId, + outcome: { + type: 'interrupt', + interrupts: [{ id: 'pause', reason: 'Approval' }], + }, + } + ); + await done; + expect(f.app.getSnapshot().native?.decision?.kind).toBe('native'); + expect(f.app.getSnapshot().canSubmit).toBe(false); + await f.app.submit('no resume'); + expect(f.requests).toHaveLength(1); +}); +it('uncertain native continuation preserves local sent-unconfirmed evidence and requires New', async () => { + const f = fixture(); + const done = f.app.submit('Weather'); + await f.next(); + f.finish(0, 'get_weather', { location: 'Paris' }); + const next = await f.next(1); + next.end(); + await done; + expect(f.app.getSnapshot().canSubmit).toBe(false); + expect(f.app.getSnapshot().native?.clientTools.calls[0]).toMatchObject({ + status: 'complete', + delivery: 'sent-unconfirmed', + }); + expect(f.app.getSnapshot().native?.clientTools.blocker?.reason).toBe( + 'uncertain' + ); +}); +it('child-attributed native calls remain observations and do not authorize local weather', async () => { + const f = fixture(); + const done = f.app.submit('Child'); + const first = await f.next(); + first.send( + { + type: 'RUN_STARTED', + threadId: first.body.threadId, + runId: first.body.runId, + }, + { + type: 'MESSAGES_SNAPSHOT', + messages: [ + ...first.body.messages, + { + id: 'child-assistant', + role: 'assistant', + subagentRunId: 'child', + toolCalls: [ + { + id: 'child-call', + type: 'function', + function: { + name: 'weather_snapshot', + arguments: JSON.stringify(weather), + }, + }, + ], + }, + ], + }, + { + type: 'RUN_FINISHED', + threadId: first.body.threadId, + runId: first.body.runId, + } + ); + await done; + expect(f.app.getSnapshot().native?.clientTools.calls).toHaveLength(0); + expect(f.app.getSnapshot().views.flatMap((row) => row.tools)).toEqual([ + expect.objectContaining({ id: 'child-call', name: 'weather_snapshot' }), + ]); + expect( + f.app.getSnapshot().views.flatMap((row) => row.tools)[0].local + ).toBeUndefined(); + expect(f.app.getSnapshot().canSubmit).toBe(false); +}); +it('factory reentrant Stop disposes the returned native candidate without submitting', async () => { + const submit = vi.fn(async () => 'success' as const), + dispose = vi.fn(async () => undefined); + const app = createClientToolsApplication({ + sessionFactory(threadId, tools) { + void app.stop(); + const native = createSession( + { threadId, url: 'http://synthetic.invalid/never' }, + tools + ); + return { ...native, submit, dispose }; + }, + }); + await app.submit('one'); + expect(submit).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledOnce(); + await app.newConversation(); + expect(app.getSnapshot().threadId).toBeNull(); + await app.dispose(); +}); +it('getSnapshot method access that stops the app cannot invoke a revoked read', async () => { + let armed = false; + const read = vi.fn(() => { + throw new Error('revoked read'); + }); + const app = createClientToolsApplication({ + sessionFactory(threadId, tools) { + const native = createSession( + { threadId, url: 'http://synthetic.invalid/never' }, + tools + ); + return { + ...native, + get getSnapshot() { + if (armed) { + void app.stop(); + return read; + } + return native.getSnapshot; + }, + submit() { + armed = true; + return Promise.resolve('success' as const); + }, + }; + }, + }); + await app.submit('one'); + expect(read).not.toHaveBeenCalled(); + expect(app.getSnapshot().canSubmit).toBe(false); + await app.dispose(); +}); +// Compile-only frozen application and heterogeneous native contracts proof. +export function checkApplicationTypes( + app: ReturnType +) { + const state = app.getSnapshot(); + // @ts-expect-error App snapshots are readonly. + state.busy = false; + // @ts-expect-error Booking observations are readonly arrays. + state.bookings.push({}); + // @ts-expect-error Message projections are readonly arrays. + state.views.push({}); + for (const call of state.native?.clientTools.calls ?? []) { + if (call.name === 'confirm_booking') { + const confirmed: boolean | undefined = call.result?.confirmed; + void confirmed; + // @ts-expect-error Booking does not widen to weather fields. + void call.args?.location; + } + } +} + +it('actual native SDK over loopback HTTP preserves terminal result and owns exactly one booking continuation (synthetic HTTP fixture)', async () => { + const requests: { body: RunAgentInput; response: ServerResponse }[] = []; + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.flushHeaders(); + requests.push({ + body: JSON.parse(Buffer.concat(chunks).toString()) as RunAgentInput, + response, + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const app = createClientToolsApplication({ + sessionFactory(threadId, tools) { + return createSession( + { + threadId, + url: `http://127.0.0.1:${ + (server.address() as AddressInfo).port + }/synthetic`, + }, + tools + ); + }, + }); + function finish(index: number, name?: string, args: unknown = {}) { + const { body, response } = requests[index]; + const message = name + ? { + id: `http-assistant-${index}`, + role: 'assistant', + toolCalls: [ + { + id: `http-call-${index}`, + type: 'function', + function: { name, arguments: JSON.stringify(args) }, + }, + ], + } + : { + id: `http-answer-${index}`, + role: 'assistant', + content: 'Synthetic HTTP answer', + }; + for (const event of [ + { type: 'RUN_STARTED', threadId: body.threadId, runId: body.runId }, + { type: 'MESSAGES_SNAPSHOT', messages: [...body.messages, message] }, + { type: 'RUN_FINISHED', threadId: body.threadId, runId: body.runId }, + ]) + response.write(`data: ${JSON.stringify(event)}\n\n`); + } + try { + expect(requests).toHaveLength(0); + const first = app.submit('Terminal'); + await until(() => requests.length === 1); + finish(0, 'weather_snapshot', weather); + await first; + expect(requests).toHaveLength(1); + expect(app.getSnapshot().canSubmit).toBe(true); + expect(app.getSnapshot().native?.clientTools.calls[0].delivery).toBe( + 'unsent' + ); + const second = app.submit('Booking'); + await until(() => requests.length === 2); + expect( + requests[1].body.messages.filter((message) => message.role === 'tool') + ).toHaveLength(1); + finish(1, 'confirm_booking', { summary: 'HTTP fictional table' }); + await until(() => app.getSnapshot().bookings.length === 1); + const panel = app.getSnapshot().bookings[0]; + expect(panel.callId).toBe('http-call-1'); + expect(app.respond(panel.token, true)).toBe(true); + await until(() => requests.length === 3); + expect(requests[2].body.threadId).toBe(requests[1].body.threadId); + expect(requests[2].body.runId).not.toBe(requests[1].body.runId); + expect( + requests[2].body.messages.filter((message) => message.role === 'user') + ).toHaveLength(2); + expect(requests[2].body.tools.map((tool) => tool.name)).toEqual([ + 'get_weather', + 'slow_status_check', + 'weather_card', + 'weather_snapshot', + 'confirm_booking', + ]); + finish(2); + await second; + expect(app.getSnapshot().canSubmit).toBe(true); + } finally { + await app.dispose(); + const closed = once(server, 'close'); + server.close(); + server.closeAllConnections(); + await closed; + } +}); +it('New excludes reentrant Send from native lifecycle before clearing the old display (focused lifecycle seam)', async () => { + const submit = vi.fn(async () => 'success' as const), + factory = vi.fn(); + let armed = false; + const app = createClientToolsApplication({ + sessionFactory(threadId, tools) { + factory(); + const native = createSession( + { threadId, url: 'http://synthetic.invalid/never' }, + tools + ); + return { + ...native, + getSnapshot() { + const snapshot = native.getSnapshot(); + return { + ...snapshot, + clientTools: { + ...snapshot.clientTools, + commandOutcome: 'success' as const, + }, + }; + }, + submit, + stop: async () => { + if (armed) void app.submit('reentrant lifecycle'); + }, + dispose: async () => { + if (armed) void app.submit('reentrant cleanup'); + }, + }; + }, + }); + await app.submit('first'); + expect(app.getSnapshot().canSubmit).toBe(true); + armed = true; + await app.newConversation(); + expect(submit).toHaveBeenCalledTimes(1); + expect(factory).toHaveBeenCalledTimes(1); + expect(app.getSnapshot()).toMatchObject({ + threadId: null, + native: undefined, + busy: false, + canSubmit: true, + }); + await app.dispose(); +}); +it('settlement eligibility getter Stop cannot be overwritten by stale successful publication (synthetic SDK seam)', async () => { + let armed = true; + const f = fixture( + (native) => + new Proxy(native, { + get(target, key, receiver) { + if ( + key === 'status' && + armed && + target.status === 'idle' && + target.clientTools.commandOutcome === 'success' + ) { + armed = false; + void f.app.stop(); + } + return Reflect.get(target, key, receiver); + }, + }) + ); + const done = f.app.submit('settle'); + await f.next(); + f.finish(0); + await done; + expect(armed).toBe(false); + expect(f.app.getSnapshot()).toMatchObject({ + busy: false, + canSubmit: false, + outcome: 'aborted', + }); + expect(f.app.getSnapshot().error).toContain('stopped'); +}); +it('initial admission eligibility getter New cannot be overwritten by stale rejection (synthetic SDK seam)', async () => { + let armed = false; + const f = fixture( + (native) => + new Proxy(native, { + get(target, key, receiver) { + if (key === 'status' && armed && target.status === 'idle') { + armed = false; + void f.app.newConversation(); + return 'error'; + } + return Reflect.get(target, key, receiver); + }, + }) + ); + const done = f.app.submit('first'); + await f.next(); + f.finish(0); + await done; + armed = true; + await f.app.submit('revoked'); + expect(armed).toBe(false); + expect(f.app.getSnapshot()).toMatchObject({ + busy: false, + canSubmit: true, + outcome: null, + error: null, + threadId: null, + native: undefined, + viewGeneration: 1, + }); + expect(f.requests).toHaveLength(1); +}); +it('subscribe method access Stop prevents invoking a revoked subscription (focused lifecycle seam)', async () => { + const subscribe = vi.fn((notify: () => void) => { + void notify; + return () => undefined; + }); + const app = createClientToolsApplication({ + sessionFactory(threadId, tools) { + const native = createSession( + { threadId, url: 'http://synthetic.invalid/never' }, + tools + ); + return { + ...native, + get subscribe() { + void app.stop(); + return subscribe; + }, + }; + }, + }); + await app.submit('one'); + expect(subscribe).not.toHaveBeenCalled(); + await app.dispose(); +}); +it('factory method access Stop prevents invoking a revoked factory (focused lifecycle seam)', async () => { + const factory = vi.fn((threadId: string, tools: Tools) => + createSession({ threadId, url: 'http://synthetic.invalid/never' }, tools) + ); + const app = createClientToolsApplication({ + get sessionFactory() { + void app.stop(); + return factory; + }, + }); + await app.submit('one'); + expect(factory).not.toHaveBeenCalled(); + await app.dispose(); +}); + +it('reentrant Stop during replacement publication retains Stop and a later New resets explicitly', async () => { + const factory = vi.fn((threadId: string, tools: Tools) => + createSession({ threadId, url: 'http://synthetic.invalid/never' }, tools) + ); + const app = createClientToolsApplication({ sessionFactory: factory }); + let armed = true; + app.subscribe(() => { + if (armed && app.getSnapshot().activity === 'replacing') { + armed = false; + void app.stop(); + } + }); + await app.newConversation(); + expect(factory).not.toHaveBeenCalled(); + expect(app.getSnapshot()).toMatchObject({ + busy: false, + canSubmit: false, + outcome: 'aborted', + viewGeneration: 0, + }); + await app.newConversation(); + expect(app.getSnapshot()).toMatchObject({ + busy: false, + canSubmit: true, + outcome: null, + viewGeneration: 1, + }); + await app.dispose(); +}); diff --git a/cockpit/ag-ui/client-tools/react/src/application.ts b/cockpit/ag-ui/client-tools/react/src/application.ts new file mode 100644 index 000000000..46ee14525 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/application.ts @@ -0,0 +1,440 @@ +import type { + ClientToolSession, + ClientToolSessionSnapshot, +} from '@threadplane/ag-ui'; +import type { CompleteOutcome } from '@threadplane/core'; +import type { ToolContracts } from '@threadplane/core/tools'; +import { + createClientToolsController, + validateBookingArguments, + type BookingInvocation, + type BookingPanel, +} from './tools'; +import { + projectClientToolViews, + type ClientToolMessageView, +} from './projection'; + +export type ToolsController = ReturnType; +export type Tools = ToolsController['tools']; +export type NativeSession = ClientToolSession>; +export type NativeSnapshot = ClientToolSessionSnapshot>; +export interface ClientToolsApplicationSnapshot { + readonly threadId: string | null; + readonly native: NativeSnapshot | undefined; + readonly views: readonly ClientToolMessageView[]; + readonly bookings: readonly BookingPanel[]; + readonly busy: boolean; + readonly canSubmit: boolean; + readonly activity: 'idle' | 'running' | 'replacing'; + readonly outcome: CompleteOutcome | null; + readonly error: string | null; + readonly viewGeneration: number; +} +/** App lifetime and explicit controls only. All protocol execution belongs to the native owner. */ +export function createClientToolsApplication(options: { + readonly sessionFactory: (threadId: string, tools: Tools) => NativeSession; +}) { + type Primary = { + readonly generation: number; + readonly threadId: string; + readonly tools: ToolsController; + session?: NativeSession; + closed: boolean; + unsafe: boolean; + release?: () => void; + releaseTools?: () => void; + }; + type Operation = { readonly controller: AbortController; owner?: Primary }; + const listeners = new Set<() => void>(); + let primary: Primary | undefined, operation: Operation | undefined; + let generation = 0, + disposed = false; + let snapshot: ClientToolsApplicationSnapshot = Object.freeze({ + threadId: null, + native: undefined, + views: Object.freeze([]), + bookings: Object.freeze([]), + busy: false, + canSubmit: true, + activity: 'idle', + outcome: null, + error: null, + viewGeneration: 0, + }); + function publish(update: Partial) { + if (disposed) return; + snapshot = Object.freeze({ ...snapshot, ...update }); + const publication = snapshot; + for (const notify of [...listeners]) { + if (disposed || snapshot !== publication) break; + try { + notify(); + } catch { + /* A view cannot interrupt execution or cleanup. */ + } + } + } + function current(owner: Primary, admitted?: Operation) { + return ( + !disposed && + primary === owner && + generation === owner.generation && + !owner.closed && + (!admitted || + (operation === admitted && + admitted.owner === owner && + !admitted.controller.signal.aborted)) + ); + } + // Observe every cleanup rejection without waiting for arbitrary foreign promises. + function cleanup(action: () => unknown) { + try { + void Promise.resolve(action()).catch(() => undefined); + } catch { + /* Revoked candidates cannot publish. */ + } + } + function close(owner: Primary) { + if (owner.closed) return; + owner.closed = true; + cleanup(() => owner.release?.()); + cleanup(() => owner.releaseTools?.()); + owner.tools.dispose(); + const session = owner.session; + if (session) { + cleanup(() => session.stop()); + cleanup(() => session.dispose()); + } + } + function read(owner: Primary, valid: () => boolean) { + if (!valid() || !owner.session) return; + const session = owner.session; + const getSnapshot = session.getSnapshot; + if (!valid()) return; + const native = getSnapshot.call(session); + return valid() ? native : undefined; + } + function booking(owner: Primary): BookingInvocation | undefined { + const admitted = operation; + const valid = () => !!admitted && current(owner, admitted) && snapshot.busy; + if (!valid()) return; + const native = read(owner, valid); + if (!native || !valid() || native.clientTools.blocker || !native.run?.id) + return; + const calls = native.clientTools.calls.filter( + (call) => + call.provenance === 'local' && + call.name === 'confirm_booking' && + call.status === 'running' + ); + if (!valid() || calls.length !== 1) return; + const call = calls[0]; + if (call.sourceRunId !== native.run.id) return; + const { summary } = validateBookingArguments(call.args); + if (!valid()) return; + const identity = Object.freeze({ + ownerGeneration: owner.generation, + callId: call.callId, + assistantMessageId: call.assistantMessageId, + sourceRunId: call.sourceRunId, + summary, + }); + return valid() ? identity : undefined; + } + function capture(owner: Primary, valid: () => boolean) { + const native = read(owner, valid); + if (!native || !valid()) return; + const views = projectClientToolViews(native, valid); + if (!valid()) return; + const bookings = owner.tools.getSnapshot().bookings; + return valid() ? { native, views, bookings } : undefined; + } + function observe(owner: Primary) { + const admitted = operation; + const valid = () => + current(owner) && (!admitted || current(owner, admitted)); + try { + const result = capture(owner, valid); + if (result && valid()) publish(result); + } catch { + if (valid()) { + owner.unsafe = true; + publish({ + canSubmit: false, + error: + 'The client tool session is unavailable. Start a new conversation.', + }); + } + } + } + function install(admitted: Operation): Primary | undefined { + const valid = () => + !disposed && + operation === admitted && + !admitted.controller.signal.aborted; + if (!valid()) return; + const threadId = crypto.randomUUID(); + if (!valid()) return; + const tools = createClientToolsController(() => booking(owner)); + const owner: Primary = { + threadId, + generation: ++generation, + tools, + closed: false, + unsafe: false, + }; + if (!valid()) { + close(owner); + return; + } + primary = owner; + admitted.owner = owner; + try { + const factory = options.sessionFactory; + if (!current(owner, admitted)) return; + const session = factory.call(options, threadId, tools.tools); + owner.session = session; + if (!current(owner, admitted)) { + close(owner); + cleanup(() => session.dispose()); + return; + } + const releaseTools = tools.subscribe(() => observe(owner)); + owner.releaseTools = releaseTools; + if (!current(owner, admitted)) { + cleanup(releaseTools); + close(owner); + return; + } + const subscribe = session.subscribe; + if (!current(owner, admitted)) return; + const release = subscribe.call(session, () => observe(owner)); + owner.release = release; + if (!current(owner, admitted)) { + cleanup(release); + close(owner); + return; + } + const initial = capture(owner, () => current(owner, admitted)); + if (!initial || !current(owner, admitted)) { + close(owner); + return; + } + publish({ threadId, ...initial }); + return current(owner, admitted) ? owner : undefined; + } catch (error) { + close(owner); + if (primary === owner) primary = undefined; + throw error; + } + } + function eligible(native: NativeSnapshot, valid: () => boolean) { + if (!valid()) return false; + const status = native.status; + if (!valid() || status !== 'idle') return false; + const tools = native.clientTools; + if (!valid()) return false; + const outcome = tools.commandOutcome; + if (!valid() || outcome !== 'success') return false; + const blocker = tools.blocker; + if (!valid() || blocker) return false; + const decision = native.decision; + if (!valid() || decision) return false; + const failed = tools.calls.some( + (call) => call.status === 'error' || call.status === 'cancelled' + ); + if (!valid() || failed) return false; + const child = native.transcript.some( + (message) => + message.role === 'assistant' && + typeof message.subagentRunId === 'string' && + !!message.toolCalls?.length + ); + return valid() && !child; + } + async function submit(text: string) { + if ( + disposed || + snapshot.busy || + !snapshot.canSubmit || + typeof text !== 'string' || + !text.trim() + ) + return; + const before = primary; + const initialValid = () => + !disposed && + !snapshot.busy && + snapshot.canSubmit && + primary === before && + !before?.closed; + try { + if (before) { + const native = read(before, initialValid); + if (!native || !initialValid()) return; + const safe = !before.unsafe && eligible(native, initialValid); + if (!initialValid()) return; + if (!safe) { + publish({ + canSubmit: false, + error: 'Start a new conversation to continue.', + }); + return; + } + } + } catch { + if (initialValid()) + publish({ + canSubmit: false, + error: + 'The client tool session is unavailable. Start a new conversation.', + }); + return; + } + if (!initialValid()) return; + const admitted: Operation = { + controller: new AbortController(), + owner: before, + }; + operation = admitted; + const valid = () => + !disposed && + operation === admitted && + !admitted.controller.signal.aborted; + publish({ + busy: true, + canSubmit: false, + activity: 'running', + outcome: null, + error: null, + }); + try { + if (!valid()) return; + const owner = before ?? install(admitted); + if (!owner || !current(owner, admitted) || !owner.session) return; + const session = owner.session; + const execute = session.submit; + if (!current(owner, admitted)) return; + const pending = execute.call(session, text, { + signal: admitted.controller.signal, + }); + // Adoption observes late rejection even if callbacks detach the owner immediately. + void Promise.resolve(pending).catch(() => undefined); + if (!current(owner, admitted)) return; + const outcome = await pending; + if (!current(owner, admitted)) return; + const result = capture(owner, () => current(owner, admitted)); + if (!result || !current(owner, admitted)) return; + const safe = + outcome === 'success' && + eligible(result.native, () => current(owner, admitted)) && + !owner.unsafe; + if (!current(owner, admitted)) return; + owner.unsafe ||= !safe; + publish({ + ...result, + outcome, + canSubmit: safe, + error: safe + ? null + : 'The client tool command did not complete safely. Start a new conversation.', + }); + } catch { + if (valid()) { + if (admitted.owner) admitted.owner.unsafe = true; + publish({ + canSubmit: false, + outcome: 'error', + error: 'The AG-UI request failed. Start a new conversation.', + }); + } + } finally { + if (!disposed && operation === admitted) { + operation = undefined; + publish({ busy: false, activity: 'idle' }); + } + } + } + async function stop() { + if (disposed || !operation) return; + const admitted = operation, + owner = primary; + operation = undefined; + primary = undefined; + admitted.controller.abort(); + if (owner) close(owner); + publish({ + busy: false, + canSubmit: false, + activity: 'idle', + outcome: 'aborted', + ...(owner ? { bookings: owner.tools.getSnapshot().bookings } : {}), + error: 'The operation stopped. Start a new conversation.', + }); + } + async function newConversation() { + if (disposed || snapshot.busy) return; + const replacement: Operation = { controller: new AbortController() }; + operation = replacement; + const valid = () => + !disposed && + operation === replacement && + !replacement.controller.signal.aborted; + publish({ busy: true, canSubmit: false, activity: 'replacing' }); + if (!valid()) return; + const owner = primary; + primary = undefined; + generation++; + if (owner) close(owner); + if (!valid()) return; + operation = undefined; + publish({ + threadId: null, + native: undefined, + views: Object.freeze([]), + bookings: Object.freeze([]), + busy: false, + canSubmit: true, + activity: 'idle', + outcome: null, + error: null, + viewGeneration: snapshot.viewGeneration + 1, + }); + } + function respond(token: string, decision: boolean) { + const owner = primary, + admitted = operation; + if (!owner || !admitted || !current(owner, admitted) || !snapshot.busy) + return false; + return owner.tools.respond(token, decision); + } + function dispose(): Promise { + if (disposed) return Promise.resolve(); + disposed = true; + const owner = primary; + primary = undefined; + const admitted = operation; + operation = undefined; + generation++; + admitted?.controller.abort(); + if (owner) close(owner); + listeners.clear(); + return Promise.resolve(); + } + return Object.freeze({ + getSnapshot: () => snapshot, + subscribe(notify: () => void) { + if (disposed) return () => undefined; + listeners.add(notify); + return () => { + listeners.delete(notify); + }; + }, + submit, + stop, + newConversation, + respond, + dispose, + }); +} diff --git a/cockpit/ag-ui/client-tools/react/src/confirm-booking.tsx b/cockpit/ag-ui/client-tools/react/src/confirm-booking.tsx new file mode 100644 index 000000000..65e571c1d --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/confirm-booking.tsx @@ -0,0 +1,34 @@ +import type { BookingPanel } from './tools'; +export function ConfirmBooking({ + booking, + respond, +}: { + readonly booking: BookingPanel; + readonly respond: (token: string, confirmed: boolean) => void; +}) { + return ( +
+

Fictional booking

+

{booking.summary}

+

No real reservation will be made.

+ {booking.status === 'pending' ? ( +
+ {' '} + +
+ ) : ( +

+ {booking.status === 'aborted' + ? 'Booking decision cancelled.' + : booking.confirmed + ? 'You confirmed this fictional booking.' + : 'You cancelled this fictional booking.'} +

+ )} +
+ ); +} diff --git a/cockpit/ag-ui/client-tools/react/src/connection.spec.ts b/cockpit/ag-ui/client-tools/react/src/connection.spec.ts new file mode 100644 index 000000000..723c41fa7 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/connection.spec.ts @@ -0,0 +1,120 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { + clientToolsConnection, + createConnectedApplication, +} from './connection'; +const mocks = vi.hoisted(() => ({ create: vi.fn() })); +vi.mock('@threadplane/ag-ui', async (original) => ({ + ...(await original()), + createSession: mocks.create, +})); +beforeEach(() => vi.resetAllMocks()); +it('uses authored native developer configuration or the exact fixed shared route', () => { + expect(clientToolsConnection({ status: 'default' })).toEqual({ + url: new URL('/ag-ui/client-tools/agent', window.location.origin).href, + label: 'Shared runtime', + }); + expect( + clientToolsConnection({ + status: 'configured', + generation: 1, + reportOperationFailure: vi.fn(), + target: { kind: 'ag-ui', endpoint: 'https://developer.test/agent' }, + }) + ).toEqual({ + url: 'https://developer.test/agent', + label: 'Developer runtime', + }); +}); +it.each(['error', 'langsmith', 'invalid-url'] as const)( + 'rejects %s configuration without leaking secrets', + (kind) => { + expect(() => + clientToolsConnection( + kind === 'error' + ? { status: 'error', code: 'incompatible_bridge' } + : { + status: 'configured', + generation: 1, + reportOperationFailure: vi.fn(), + target: + kind === 'langsmith' + ? { + kind: 'langsmith', + apiUrl: 'https://private.test', + apiKey: 'SECRET', + } + : { kind: 'ag-ui', endpoint: 'javascript:SECRET' }, + } + ) + ).toThrow('Runtime configuration failed.'); + } +); +it('is inert before Send and supplies real five-tool catalog as the native second argument', async () => { + const native = { + status: 'idle', + transcript: [], + state: {}, + subagents: [], + clientTools: { calls: [] }, + }; + const submit = vi.fn(async () => 'interrupted'); + const dispose = vi.fn(); + mocks.create.mockReturnValue({ + getSnapshot: () => native, + subscribe: () => () => undefined, + submit, + resume: vi.fn(), + stop: vi.fn(), + dispose, + }); + const app = createConnectedApplication({ + url: 'https://developer.test/agent', + label: 'Developer runtime', + }); + expect(mocks.create).not.toHaveBeenCalled(); + await app.submit(' authored '); + expect(mocks.create).toHaveBeenCalledWith( + { + threadId: app.getSnapshot().threadId, + url: 'https://developer.test/agent', + }, + expect.objectContaining({ + confirm_booking: expect.objectContaining({ + handler: expect.any(Function), + }), + }) + ); + expect(submit).toHaveBeenCalledWith(' authored ', { + signal: expect.any(AbortSignal), + }); + await app.dispose(); +}); +it.each([ + undefined, + {}, + { status: 'idle', transcript: [], subagents: [], clientTools: {} }, +])( + 'rejects missing typed native clientTools capability before transport', + async (native) => { + const dispose = vi.fn(); + const submit = vi.fn(); + mocks.create.mockReturnValue({ + getSnapshot: () => native, + submit, + dispose, + }); + const app = createConnectedApplication({ + url: 'https://developer.test/agent', + label: 'Developer runtime', + }); + await app.submit('no'); + expect(submit).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledTimes(1); + expect(app.getSnapshot()).toMatchObject({ + threadId: null, + canSubmit: false, + outcome: 'error', + }); + } +); diff --git a/cockpit/ag-ui/client-tools/react/src/connection.ts b/cockpit/ag-ui/client-tools/react/src/connection.ts new file mode 100644 index 000000000..de94fb52d --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/connection.ts @@ -0,0 +1,76 @@ +import { createSession } from '@threadplane/ag-ui'; +import { createClientToolsApplication } from './application'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- Isolated build copies the neutral internal bridge, not a published package. +import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index'; +export interface ClientToolsConnection { + readonly url: string; + readonly label: 'Shared runtime' | 'Developer runtime'; +} +export function clientToolsConnection( + configuration: RuntimeBridgeConfiguration +): ClientToolsConnection { + if ( + configuration.status === 'error' || + (configuration.status === 'configured' && + configuration.target.kind === 'langsmith') + ) + throw new Error('Runtime configuration failed.'); + const target = + configuration.status === 'configured' && + configuration.target.kind === 'ag-ui' + ? configuration.target + : undefined; + const url = + target?.endpoint ?? + new URL('/ag-ui/client-tools/agent', window.location.origin).href; + try { + const parsed = new URL(url); + if ( + !['http:', 'https:'].includes(parsed.protocol) || + parsed.username || + parsed.password + ) + throw new Error(); + } catch { + throw new Error('Runtime configuration failed.'); + } + return Object.freeze({ + url, + label: target ? 'Developer runtime' : 'Shared runtime', + }); +} +export function createConnectedApplication(connection: ClientToolsConnection) { + return createClientToolsApplication({ + sessionFactory(threadId, tools) { + const session = createSession({ threadId, url: connection.url }, tools); + try { + const native = session.getSnapshot(); + if ( + !native || + !Array.isArray(native.transcript) || + !Array.isArray(native.subagents) || + !['idle', 'running', 'error'].includes(native.status) || + !native.clientTools || + !Array.isArray(native.clientTools.calls) || + ![ + session.getSnapshot, + session.subscribe, + session.submit, + session.resume, + session.stop, + session.dispose, + ].every((method) => typeof method === 'function') + ) + throw new Error(); + } catch { + try { + void Promise.resolve(session.dispose()).catch(() => undefined); + } catch { + /* Rejected candidate has no authority. */ + } + throw new Error('Runtime session is unavailable.'); + } + return session; + }, + }); +} diff --git a/cockpit/ag-ui/client-tools/react/src/main.tsx b/cockpit/ag-ui/client-tools/react/src/main.tsx new file mode 100644 index 000000000..f82755048 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/main.tsx @@ -0,0 +1,42 @@ +import { createRoot } from 'react-dom/client'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- Isolated build copies the neutral internal bridge, not a published package. +import { + GENERATED_RUNTIME_PARENT_ORIGINS, + installRuntimeBridge, +} from '../../../../../libs/cockpit-runtime-bridge/src/index'; +import { ClientToolsDemo } from './app'; +import { clientToolsConnection } from './connection'; +import '@threadplane/react/chat/styles.css'; +import './styles.css'; +const element = document.getElementById('root'); +if (!element) throw new Error('Application root is missing'); +const bridge = installRuntimeBridge(undefined, { + allowedParentOrigins: GENERATED_RUNTIME_PARENT_ORIGINS, +}); +const root = createRoot(element); +let active = true; +window.addEventListener( + 'pagehide', + () => { + active = false; + root.unmount(); + bridge.dispose(); + }, + { once: true } +); +void bridge + .awaitConfiguration() + .then((configuration) => { + if (!active) return; + root.render( + bridge.markReady()} + /> + ); + }) + .catch(() => { + if (!active) return; + bridge.markError('bootstrap_failed'); + root.render(

The client tools example could not start.

); + }); diff --git a/cockpit/ag-ui/client-tools/react/src/projection.spec.ts b/cockpit/ag-ui/client-tools/react/src/projection.spec.ts new file mode 100644 index 000000000..e3523dec5 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/projection.spec.ts @@ -0,0 +1,142 @@ +import { expect, it } from 'vitest'; +import { projectClientToolViews } from './projection'; +import type { NativeSnapshot } from './application'; +const weather = { + location: '', + temperatureF: 68, + conditions: 'Sunny', + humidity: 55, + windMph: 8, +}; +function native(): NativeSnapshot { + return { + status: 'idle', + state: {}, + subagents: [], + run: { id: 'r', outcome: 'success' }, + transcript: [ + { + id: 'a', + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'c', + type: 'function', + function: { + name: 'weather_card', + arguments: JSON.stringify(weather), + }, + }, + ], + }, + ], + clientTools: { + commandOutcome: 'success', + calls: [ + { + name: 'weather_card', + callId: 'c', + assistantMessageId: 'a', + sourceRunId: 'r', + rawArguments: JSON.stringify(weather), + args: weather, + result: { displayed: true }, + status: 'complete', + delivery: 'unsent', + provenance: 'local', + }, + ], + }, + }; +} +it('projects literal native observation separately from admitted local execution and validated weather', () => { + const state = native(); + const views = projectClientToolViews(state); + expect(views[0].text?.content).toBe(''); + expect(views[0].tools[0]).toMatchObject({ + id: 'c', + name: 'weather_card', + argumentsText: JSON.stringify(weather), + local: state.clientTools.calls[0], + weather, + sourceRunId: 'r', + }); + expect(views[0].tools[0].resultText).toBeUndefined(); + expect(Object.isFrozen(views)).toBe(true); + expect(Object.isFrozen(views[0].tools)).toBe(true); +}); +it.each(['running', 'error', 'cancelled', 'pending'] as const)( + 'does not grant weather panels from %s local calls or raw args', + (status) => { + const state = native(); + const call = state.clientTools.calls[0]; + const changed: NativeSnapshot = { + ...state, + clientTools: { calls: [{ ...call, status }] }, + }; + expect(projectClientToolViews(changed)[0].tools[0].weather).toBeUndefined(); + } +); +it('does not grant panels to ambiguous, mismatched, child, unknown or conflicting calls', () => { + const state = native(); + const call = state.clientTools.calls[0]; + expect( + projectClientToolViews({ + ...state, + clientTools: { calls: [call, call] }, + })[0].tools[0].weather + ).toBeUndefined(); + expect( + projectClientToolViews({ + ...state, + clientTools: { calls: [{ ...call, assistantMessageId: 'other' }] }, + })[0].tools[0].weather + ).toBeUndefined(); + expect( + projectClientToolViews({ + ...state, + transcript: [{ ...state.transcript[0], subagentRunId: 'child' }], + })[0].tools[0].weather + ).toBeUndefined(); + expect( + projectClientToolViews({ ...state, clientTools: { calls: [] } })[0].tools[0] + .weather + ).toBeUndefined(); + expect( + projectClientToolViews({ + ...state, + clientTools: { + calls: [call], + blocker: { reason: 'conflict', sourceRunId: 'r', callIds: ['c'] }, + }, + })[0].tools[0].weather + ).toBeUndefined(); +}); +it('revoked projection has no publication authority', () => { + expect(projectClientToolViews(native(), () => false)).toEqual([]); +}); + +it('child native call and result remain literal protected observations without local authority', () => { + const state = native(); + const child: NativeSnapshot = { + ...state, + transcript: [ + { ...state.transcript[0], subagentRunId: 'child' }, + { + id: 'child-result', + role: 'tool', + subagentRunId: 'child', + toolCallId: 'c', + content: '', + }, + ], + }; + expect(projectClientToolViews(child)[0].tools[0]).toEqual({ + id: 'c', + name: 'weather_card', + argumentsText: JSON.stringify(weather), + resultText: '', + }); + expect(projectClientToolViews(child)[0].text).toBeUndefined(); +}); diff --git a/cockpit/ag-ui/client-tools/react/src/projection.ts b/cockpit/ag-ui/client-tools/react/src/projection.ts new file mode 100644 index 000000000..c7f301ba9 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/projection.ts @@ -0,0 +1,103 @@ +import { + projectTextTranscript, + type TextTranscriptRow, +} from '@threadplane/ag-ui'; +import type { NativeSnapshot } from './application'; +import { validateWeatherArguments, type WeatherArguments } from './tools'; +type LocalCall = NativeSnapshot['clientTools']['calls'][number]; +export interface ClientToolView { + readonly id: string; + readonly name: string; + readonly argumentsText: string; + readonly resultText?: string; + readonly sourceRunId?: string; + readonly local?: LocalCall; + readonly weather?: Readonly; +} +export interface ClientToolMessageView { + readonly id: string; + readonly text?: TextTranscriptRow; + readonly tools: readonly ClientToolView[]; +} +/** Literal protocol rows plus separate matched local evidence. Never creates request history. */ +export function projectClientToolViews( + native: NativeSnapshot, + valid: () => boolean = () => true +): readonly ClientToolMessageView[] { + const rows: ClientToolMessageView[] = []; + if (!valid()) return Object.freeze([]); + const text = new Map( + projectTextTranscript(native.transcript).map((row) => [row.id, row]) + ); + for (const message of native.transcript) { + if (!valid()) return Object.freeze([]); + if (message.role !== 'assistant' && message.role !== 'user') continue; + const tools: ClientToolView[] = []; + for (const call of message.role === 'assistant' + ? message.toolCalls ?? [] + : []) { + if (!valid()) return Object.freeze([]); + const candidates = + typeof message.subagentRunId === 'string' + ? [] + : native.clientTools.calls.filter( + (local) => + local.provenance === 'local' && + local.callId === call.id && + local.assistantMessageId === message.id && + local.name === call.function.name && + local.rawArguments === call.function.arguments + ); + const blocked = native.clientTools.blocker?.callIds.includes(call.id); + const local = + candidates.length === 1 && !blocked ? candidates[0] : undefined; + let weather: Readonly | undefined; + if (local?.status === 'complete') { + try { + if (local.name === 'get_weather') { + const value = validateWeatherArguments(local.result); + if (value.location === local.args?.location) + weather = Object.freeze(value); + } else if ( + (local.name === 'weather_card' || + local.name === 'weather_snapshot') && + local.result?.displayed === true + ) { + weather = Object.freeze(validateWeatherArguments(local.args)); + } + } catch { + /* Invalid business data remains literal native evidence. */ + } + } + const results = native.transcript.filter( + (row) => + row.role === 'tool' && + row.toolCallId === call.id && + (typeof message.subagentRunId === 'string' + ? row.subagentRunId === message.subagentRunId + : typeof row.subagentRunId !== 'string') + ); + const result = results.length === 1 ? results[0] : undefined; + tools.push( + Object.freeze({ + id: call.id, + name: call.function.name, + argumentsText: call.function.arguments, + ...(result && typeof result.content === 'string' + ? { resultText: result.content } + : {}), + ...(local ? { local, sourceRunId: local.sourceRunId } : {}), + ...(weather ? { weather } : {}), + }) + ); + } + rows.push( + Object.freeze({ + id: message.id, + ...(text.has(message.id) ? { text: text.get(message.id) } : {}), + tools: Object.freeze(tools), + }) + ); + } + return valid() ? Object.freeze(rows) : Object.freeze([]); +} diff --git a/cockpit/ag-ui/client-tools/react/src/styles.css b/cockpit/ag-ui/client-tools/react/src/styles.css new file mode 100644 index 000000000..efe05772e --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/styles.css @@ -0,0 +1,143 @@ +:root { + font-family: system-ui, sans-serif; + color: #202832; + background: #fafafa; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +.client-tools-demo { + max-width: 56rem; + margin: auto; + padding: 1.5rem; + min-height: 100dvh; + display: flex; + flex-direction: column; + gap: 1rem; + overflow-wrap: anywhere; +} +.client-tools-demo header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} +.client-tools-demo header > button { + flex-shrink: 0; + max-width: 9rem; + padding: 0.4rem 0.6rem; + overflow-wrap: normal; +} +.client-tools-demo h1 { + font-size: 1.5rem; + margin: 0; +} +.client-tools-demo p { + margin: 0; +} +.client-tools-demo .eyebrow { + color: #606b76; + font-size: 0.8rem; + margin-bottom: 0.4rem; +} +.client-tools-demo button { + font: inherit; + cursor: pointer; +} +.client-tools-demo button:disabled { + cursor: default; +} +.client-tools-demo section[aria-label='Conversation'] { + flex: 1; + min-height: 8rem; + max-height: 55dvh; +} +.client-tools-demo section { + border: 1px solid #dce1e6; + border-radius: 0.5rem; + padding: 1rem; +} +.client-tools-demo section h2 { + margin: 0 0 0.5rem; + font-size: 1.1rem; +} +.client-tools-demo section p + p { + margin-top: 0.5rem; +} +.client-tools-demo [role='alert'] { + color: #a52323; +} +.client-tools-demo [role='status'] { + font-size: 0.85rem; + color: #606b76; +} +.client-tools-demo dl { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.5rem 1rem; + margin: 0 0 1rem; +} +.client-tools-demo dd { + margin: 0; +} +.client-tools-demo label { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.client-tools-demo input { + font: inherit; + width: 100%; + max-width: 15rem; + padding: 0.4rem; +} +@media (max-width: 32rem) { + .client-tools-demo { + padding: 1rem; + } + .client-tools-demo header { + align-items: flex-start; + } +} +.client-tools-conversation { + min-height: 12rem; + overflow: auto; +} +.client-tools-conversation > ol { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 1rem; +} +.weather-card { + padding: 1rem; + background: #f1f5f9; + border: 1px solid #d8dee8; + border-radius: 0.5rem; + overflow-wrap: anywhere; +} +.weather-card dl { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.5rem; +} +.weather-card dd { + margin: 0; +} +.client-tools-conversation details { + margin-block: 0.5rem; +} + +.prompt-presets { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.prompt-presets button, +.booking-panel button { + padding: 0.4rem 0.6rem; +} diff --git a/cockpit/ag-ui/client-tools/react/src/tools.spec.ts b/cockpit/ag-ui/client-tools/react/src/tools.spec.ts new file mode 100644 index 000000000..d93925d2a --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/tools.spec.ts @@ -0,0 +1,266 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createClientToolsController, type BookingInvocation } from './tools'; + +const weather = { location: 'Denver', temperatureF: 68, conditions: 'Sunny', humidity: 55, windMph: 8 }; +const invocation: BookingInvocation = { + ownerGeneration: 1, callId: 'call-real', assistantMessageId: 'assistant-real', + sourceRunId: 'run-real', summary: 'Fictional Denver visit', +}; +function setup() { + let current: BookingInvocation | undefined = invocation; + const read = vi.fn(() => current); + const controller = createClientToolsController(read); + const abort = new AbortController(); + return { controller, abort, read, setCurrent(value: BookingInvocation | undefined) { current = value; } }; +} +afterEach(() => vi.useRealTimers()); + +describe('native five-tool business catalog', () => { + it('constructs and subscribes inertly and exposes exactly five authored schemas/policies', () => { + const { controller, read } = setup(); + const unsubscribe = controller.subscribe(() => undefined); + expect(read).not.toHaveBeenCalled(); + expect(Object.keys(controller.tools)).toEqual(['get_weather', 'slow_status_check', 'weather_card', 'weather_snapshot', 'confirm_booking']); + for (const tool of Object.values(controller.tools)) { + expect(tool.parameters).toMatchObject({ type: 'object', additionalProperties: false }); + expect(tool.followUp).toBe(tool === controller.tools.weather_snapshot ? false : true); + } + expect(controller.tools.get_weather.description).toContain('simulated'); + expect(controller.tools.confirm_booking.description).toContain('fictional'); + expect(controller.tools.slow_status_check.parameters).toMatchObject({ required: [], properties: { label: { default: 'status check' } } }); + expect(controller.tools.weather_card.parameters).toEqual(controller.tools.weather_snapshot.parameters); + expect(controller.tools.weather_card.parameters).toMatchObject({ required: Object.keys(weather) }); + unsubscribe(); controller.dispose(); + }); + it('returns asynchronous simulated data isolated across invocations and descriptors', async () => { + const { controller, abort } = setup(); + const first = controller.tools.get_weather.handler({ location: 'Denver' }, abort); + expect(first).toBeInstanceOf(Promise); + const result = await first; + expect(result).toEqual(weather); + result.location = 'changed'; + expect(await controller.tools.get_weather.handler({ location: 'Denver' }, abort)).toEqual(weather); + expect(Object.isFrozen(controller.tools.get_weather.parameters)).toBe(true); + expect(Object.isFrozen(controller.tools.weather_card.parameters)).toBe(true); + expect(Object.isFrozen(controller.tools)).toBe(true); + }); + it('acknowledges both display tools with portable displayed:true without duplicating weather history', async () => { + const { controller, abort } = setup(); + expect(await controller.tools.weather_card.handler(weather, abort)).toEqual({ displayed: true }); + expect(await controller.tools.weather_snapshot.handler(weather, abort)).toEqual({ displayed: true }); + expect(controller.getSnapshot()).toEqual({ bookings: [] }); + }); + it.each([null, [], Object.create({ location: 'Denver' }), { location: '' }, { location: ' ' }, { location: 7 }, { location: 'Denver', extra: true }, Object.defineProperty({}, 'location', { get() { throw new Error('getter ran'); }, enumerable: true })])('rejects malformed location arguments case %# without reading getters', async (input: unknown) => { + const { controller, abort } = setup(); + // Runtime admission is independently validated even if caller bypasses authored types. + // @ts-expect-error deliberately malformed runtime arguments + await expect(controller.tools.get_weather.handler(input, abort)).rejects.toThrow('Invalid tool arguments'); + }); + it.each([ + null, {}, { ...weather, conditions: '' }, { ...weather, temperatureF: Infinity }, + { ...weather, humidity: NaN }, { ...weather, humidity: -1 }, { ...weather, humidity: 101 }, + { ...weather, windMph: -1 }, { ...weather, temperatureF: '68' }, { ...weather, extra: 1 }, + Object.defineProperty({ ...weather }, 'conditions', { get() { throw new Error('getter ran'); } }), + Object.assign(Object.create({}), weather), + ])('rejects malformed complete weather case %# for both tools', async (input: unknown) => { + const { controller, abort } = setup(); + for (const tool of [controller.tools.weather_card, controller.tools.weather_snapshot]) { + // @ts-expect-error deliberately malformed runtime arguments + await expect(tool.handler(input, abort)).rejects.toThrow('Invalid tool arguments'); + } + }); + it('accepts weather bounds and rejects all work on cancelled/disposed ownership', async () => { + const { controller, abort } = setup(); + expect(await controller.tools.weather_card.handler({ ...weather, humidity: 0, windMph: 0 }, abort)).toEqual({ displayed: true }); + expect(await controller.tools.weather_card.handler({ ...weather, humidity: 100 }, abort)).toEqual({ displayed: true }); + abort.abort(); + await expect(controller.tools.get_weather.handler({ location: 'Denver' }, abort)).rejects.toMatchObject({ name: 'AbortError' }); + await expect(controller.tools.weather_card.handler(weather, abort)).rejects.toMatchObject({ name: 'AbortError' }); + controller.dispose(); + await expect(controller.tools.weather_snapshot.handler(weather, new AbortController())).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); + +describe('owned slow delay', () => { + it('waits exactly 3000ms with authored default and removes its listener on success', async () => { + vi.useFakeTimers(); + const { controller, abort } = setup(); + const remove = vi.spyOn(abort.signal, 'removeEventListener'); + const result = controller.tools.slow_status_check.handler({}, abort); + const settled = vi.fn(); void result.then(settled); + await vi.advanceTimersByTimeAsync(2999); expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toEqual({ label: 'status check', status: 'complete' }); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + }); + it.each(['abort', 'dispose'] as const)('cleans pending delay on %s with no late result', async (action) => { + vi.useFakeTimers(); + const { controller, abort } = setup(); + const remove = vi.spyOn(abort.signal, 'removeEventListener'); + const result = controller.tools.slow_status_check.handler({ label: 'Probe' }, abort); + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + await vi.advanceTimersByTimeAsync(1000); + if (action === 'abort') abort.abort(); else controller.dispose(); + await rejected; + await vi.advanceTimersByTimeAsync(5000); + expect(vi.getTimerCount()).toBe(0); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + it('rejects already cancelled delay without creating a timer', async () => { + vi.useFakeTimers(); const { controller, abort } = setup(); abort.abort(); + await expect(controller.tools.slow_status_check.handler({}, abort)).rejects.toMatchObject({ name: 'AbortError' }); + expect(vi.getTimerCount()).toBe(0); + }); + it.each([null, { label: '' }, { label: undefined }, { label: 2 }, { extra: 1 }])('rejects malformed status case %# before scheduling', async (input: unknown) => { + vi.useFakeTimers(); const { controller, abort } = setup(); + // @ts-expect-error deliberately malformed runtime arguments + await expect(controller.tools.slow_status_check.handler(input, abort)).rejects.toThrow('Invalid tool arguments'); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('identity-bound fictional booking decisions', () => { + it('does not reconstruct the same native booking identity after cancellation', async () => { + const { controller, abort } = setup(); + const result = controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort); + const cancelled = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + abort.abort(); await cancelled; + await expect(controller.tools.confirm_booking.handler({ summary: invocation.summary }, new AbortController())).rejects.toThrow('Booking invocation unavailable'); + expect(controller.getSnapshot().bookings).toHaveLength(1); + }); + it('does not grant a fresh controller authority to an old controller token', async () => { + const old = setup(); const fresh = setup(); + const oldResult = old.controller.tools.confirm_booking.handler({ summary: invocation.summary }, old.abort); + const cancelled = expect(oldResult).rejects.toMatchObject({ name: 'AbortError' }); + const oldToken = old.controller.getSnapshot().bookings[0].token; + old.controller.dispose(); await cancelled; + const result = fresh.controller.tools.confirm_booking.handler({ summary: invocation.summary }, fresh.abort); + const newToken = fresh.controller.getSnapshot().bookings[0].token; + expect(fresh.controller.respond(oldToken, true)).toBe(false); + expect(fresh.controller.respond(newToken, false)).toBe(true); + expect(await result).toEqual({ confirmed: false }); + }); + it('revokes acquisition when pending notification changes the actual current call', async () => { + const { controller, abort, setCurrent } = setup(); + controller.subscribe(() => setCurrent(undefined)); + const result = controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort); + await expect(result).rejects.toMatchObject({ name: 'AbortError' }); + expect(controller.getSnapshot().bookings[0].status).toBe('aborted'); + }); + it('does not expose authority from malformed identity accessors or argument getters', async () => { + const { controller, abort } = setup(); + const malformed: unknown[] = [null, { summary: '' }, { summary: invocation.summary, extra: 1 }, + Object.defineProperty({}, 'summary', { get() { throw new Error('getter ran'); } })]; + for (const input of malformed) { + // @ts-expect-error hostile runtime booking arguments + await expect(controller.tools.confirm_booking.handler(input, abort)).rejects.toThrow('Invalid tool arguments'); + } + const invalid = createClientToolsController(() => Object.defineProperty({ ...invocation }, 'callId', { get() { throw new Error('getter ran'); } })); + await expect(invalid.tools.confirm_booking.handler({ summary: invocation.summary }, abort)).rejects.toThrow('Booking invocation unavailable'); + expect(invalid.getSnapshot().bookings).toEqual([]); + }); + it('cannot acquire a duplicate running booking and cancelled controls stay distinct after a new generation', async () => { + const { controller, abort, setCurrent } = setup(); + const first = controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort); + const rejected = expect(first).rejects.toMatchObject({ name: 'AbortError' }); + const old = controller.getSnapshot().bookings[0]; + await expect(controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort)).rejects.toThrow('Booking invocation unavailable'); + abort.abort(); await rejected; + setCurrent({ ...invocation, ownerGeneration: 2, callId: 'fresh-call' }); + const fresh = controller.tools.confirm_booking.handler({ summary: invocation.summary }, new AbortController()); + const next = controller.getSnapshot().bookings[1]; + expect(next.token).not.toBe(old.token); expect(controller.respond(old.token, true)).toBe(false); + expect(controller.respond(next.token, true)).toBe(true); expect(await fresh).toEqual({ confirmed: true }); + }); + it.each([true, false])('settles only the first explicit boolean %s and retains frozen visible result', async (decision) => { + const { controller, abort } = setup(); + const result = controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort); + const pending = controller.getSnapshot().bookings[0]; + expect(pending).toMatchObject({ ...invocation, status: 'pending' }); + expect(Object.isFrozen(pending)).toBe(true); + expect(Object.isFrozen(controller.getSnapshot().bookings)).toBe(true); + expect(Object.isFrozen(controller.getSnapshot())).toBe(true); + expect(controller.respond('wrong-token', decision)).toBe(false); + expect(controller.respond(pending.token, decision)).toBe(true); + expect(controller.respond(pending.token, !decision)).toBe(false); + expect(await result).toEqual({ confirmed: decision }); + expect(controller.getSnapshot().bookings[0]).toMatchObject({ status: 'resolved', confirmed: decision }); + expect(Object.isFrozen(controller.getSnapshot().bookings[0])).toBe(true); + }); + it.each([undefined, { ...invocation, summary: 'Different summary' }])('rejects unavailable or mismatched acquisition without publishing %j', async (current) => { + const { controller, abort, setCurrent } = setup(); setCurrent(current); + const notify = vi.fn(); controller.subscribe(notify); + await expect(controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort)).rejects.toThrow('Booking invocation unavailable'); + expect(controller.getSnapshot().bookings).toEqual([]); expect(notify).not.toHaveBeenCalled(); + }); + it.each(['ownerGeneration', 'callId', 'assistantMessageId', 'sourceRunId', 'summary'] as const)('refuses stale %s identities', async (field) => { + const { controller, abort, setCurrent } = setup(); + const result = controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort); + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + const pending = controller.getSnapshot().bookings[0]; + setCurrent(field === 'ownerGeneration' ? { ...invocation, ownerGeneration: 2 } : { ...invocation, [field]: 'other' }); + expect(controller.respond(pending.token, true)).toBe(false); + await rejected; + setCurrent(invocation); + expect(controller.respond(pending.token, true)).toBe(false); + expect(controller.getSnapshot().bookings[0]).toMatchObject({ status: 'aborted' }); + expect(controller.getSnapshot().bookings[0]).not.toHaveProperty('confirmed'); + }); + it.each(['missing', 'throwing'] as const)('irreversibly revokes an owned token when the accessor is %s', async (mode) => { + const { controller, abort, read, setCurrent } = setup(); + const remove = vi.spyOn(abort.signal, 'removeEventListener'); + const result = controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort); + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + const token = controller.getSnapshot().bookings[0].token; + if (mode === 'missing') setCurrent(undefined); + else read.mockImplementationOnce(() => { throw new Error('native accessor failed'); }); + expect(controller.respond(token, true)).toBe(false); + await rejected; + setCurrent(invocation); + expect(controller.respond(token, false)).toBe(false); + expect(controller.getSnapshot().bookings[0]).toMatchObject({ status: 'aborted' }); + expect(controller.getSnapshot().bookings[0]).not.toHaveProperty('confirmed'); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + it('rejects ambiguous acquisition represented by the native accessor returning undefined', async () => { + const { controller, abort, setCurrent } = setup(); setCurrent(undefined); + await expect(controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort)).rejects.toThrow('Booking invocation unavailable'); + expect(controller.getSnapshot().bookings).toEqual([]); + }); + it.each(['abort', 'dispose'] as const)('revokes before another listener can respond during pending publication: %s', async (action) => { + const { controller, abort } = setup(); + controller.subscribe(() => { if (action === 'abort') abort.abort(); else controller.dispose(); }); + const later = vi.fn(() => controller.respond(controller.getSnapshot().bookings[0].token, true)); + controller.subscribe(later); + await expect(controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort)).rejects.toMatchObject({ name: 'AbortError' }); + const row = controller.getSnapshot().bookings[0]; + expect(row.status).toBe('aborted'); expect(row).not.toHaveProperty('confirmed'); + expect(controller.respond(row.token, false)).toBe(false); + expect(later.mock.results.every((entry) => entry.value !== true)).toBe(true); + controller.dispose(); controller.dispose(); + }); + it('rechecks cancellation caused by the accessor and by resolved publication', async () => { + const abort = new AbortController(); let cancelOnRead = false; + const controller = createClientToolsController(() => { if (cancelOnRead) abort.abort(); return invocation; }); + const result = controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort); + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + const token = controller.getSnapshot().bookings[0].token; cancelOnRead = true; + expect(controller.respond(token, true)).toBe(false); await rejected; + const next = setup(); + next.controller.subscribe(() => { if (next.controller.getSnapshot().bookings[0]?.status === 'resolved') next.abort.abort(); }); + const pending = next.controller.tools.confirm_booking.handler({ summary: invocation.summary }, next.abort); + const cancelled = expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(next.controller.respond(next.controller.getSnapshot().bookings[0].token, true)).toBe(false); + await cancelled; expect(next.controller.getSnapshot().bookings[0].status).toBe('aborted'); + }); + it('refuses nonboolean decisions without settling and removes booking abort listener on explicit resolution', async () => { + const { controller, abort } = setup(); const remove = vi.spyOn(abort.signal, 'removeEventListener'); + const result = controller.tools.confirm_booking.handler({ summary: invocation.summary }, abort); + const token = controller.getSnapshot().bookings[0].token; + // @ts-expect-error runtime control input must be boolean + expect(controller.respond(token, 'yes')).toBe(false); + expect(controller.respond(token, false)).toBe(true); expect(await result).toEqual({ confirmed: false }); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + }); +}); diff --git a/cockpit/ag-ui/client-tools/react/src/tools.ts b/cockpit/ag-ui/client-tools/react/src/tools.ts new file mode 100644 index 000000000..c5136ab01 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/tools.ts @@ -0,0 +1,245 @@ +import type { FunctionTool } from '@threadplane/core/tools'; + +export interface LocationArguments { location: string } +export interface WeatherArguments extends LocationArguments { + temperatureF: number; + conditions: string; + humidity: number; + windMph: number; +} +export interface StatusArguments { label?: string } +export interface BookingArguments { summary: string } +export interface BookingInvocation { + readonly ownerGeneration: number; + readonly callId: string; + readonly assistantMessageId: string; + readonly sourceRunId: string; + readonly summary: string; +} +export type BookingPanel = BookingInvocation & { readonly token: string } & ( + | { readonly status: 'pending' } + | { readonly status: 'resolved'; readonly confirmed: boolean } + | { readonly status: 'aborted' } +); +export interface ClientToolsSnapshot { readonly bookings: readonly BookingPanel[] } + +function argumentsRecord(input: unknown, keys: readonly string[]): object { + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid tool arguments'); + const prototype = Object.getPrototypeOf(input); + if ((prototype !== Object.prototype && prototype !== null) || + Reflect.ownKeys(input).some((key) => typeof key !== 'string' || !keys.includes(key))) { + throw new Error('Invalid tool arguments'); + } + return input; +} +function field(input: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw new Error('Invalid tool arguments'); + return descriptor.value; +} +function text(input: object, key: string): string { + const value = field(input, key); + if (typeof value !== 'string' || !value.trim()) throw new Error('Invalid tool arguments'); + return value; +} +function number(input: object, key: string): number { + const value = field(input, key); + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Invalid tool arguments'); + return value; +} +const weatherKeys = ['location', 'temperatureF', 'conditions', 'humidity', 'windMph']; +/** Business validation also lets native projection admit only complete local readings. */ +export function validateWeatherArguments(input: unknown): WeatherArguments { + const record = argumentsRecord(input, weatherKeys); + const value = { + location: text(record, 'location'), temperatureF: number(record, 'temperatureF'), + conditions: text(record, 'conditions'), humidity: number(record, 'humidity'), windMph: number(record, 'windMph'), + }; + if (value.humidity < 0 || value.humidity > 100 || value.windMph < 0) throw new Error('Invalid tool arguments'); + return value; +} +export function validateBookingArguments(input: unknown): BookingArguments { + return { summary: text(argumentsRecord(input, ['summary']), 'summary') }; +} +const weatherParameters = Object.freeze({ + type: 'object', additionalProperties: false, required: Object.freeze(weatherKeys), + properties: Object.freeze({ + location: Object.freeze({ type: 'string', minLength: 1 }), + temperatureF: Object.freeze({ type: 'number' }), + conditions: Object.freeze({ type: 'string', minLength: 1 }), + humidity: Object.freeze({ type: 'number', minimum: 0, maximum: 100 }), + windMph: Object.freeze({ type: 'number', minimum: 0 }), + }), +}); +const aborted = () => new DOMException('Client tool cancelled', 'AbortError'); +function sameInvocation(left: BookingInvocation, right: BookingInvocation): boolean { + return left.ownerGeneration === right.ownerGeneration && left.callId === right.callId && + left.assistantMessageId === right.assistantMessageId && left.sourceRunId === right.sourceRunId && left.summary === right.summary; +} +/** Owns business promises only. The native session supplies every protocol identity. */ +export function createClientToolsController(readCurrentBooking: () => BookingInvocation | undefined) { + let disposed = false; + let snapshot: ClientToolsSnapshot = Object.freeze({ bookings: Object.freeze([]) }); + const listeners = new Set<() => void>(); + const cancellations = new Set<() => void>(); + const pending = new Map void; + readonly finish: (decision: boolean) => boolean; + }>(); + function active(signal: AbortSignal) { + if (disposed || signal.aborted) throw aborted(); + } + function publish(row: BookingPanel) { + const existing = snapshot.bookings.some((entry) => entry.token === row.token); + snapshot = Object.freeze({ bookings: Object.freeze(existing + ? snapshot.bookings.map((entry) => entry.token === row.token ? row : entry) + : [...snapshot.bookings, row]) }); + const publication = snapshot; + for (const notify of [...listeners]) { + if (disposed || snapshot !== publication) break; + // An observer cannot strand a tool promise or prevent other owned cleanup. + try { notify(); } catch { /* Consumers own their notification errors. */ } + } + } + function current(signal: AbortSignal): BookingInvocation | undefined { + active(signal); + const value = readCurrentBooking(); + active(signal); + if (!value) return undefined; + const record = argumentsRecord(value, ['ownerGeneration', 'callId', 'assistantMessageId', 'sourceRunId', 'summary']); + const ownerGeneration = number(record, 'ownerGeneration'); + if (!Number.isSafeInteger(ownerGeneration) || ownerGeneration < 0) return undefined; + const identity = Object.freeze({ ownerGeneration, callId: text(record, 'callId'), + assistantMessageId: text(record, 'assistantMessageId'), sourceRunId: text(record, 'sourceRunId'), summary: text(record, 'summary') }); + active(signal); + return identity; + } + function delay(signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + active(signal); + let finished = false; + const cleanup = () => { clearTimeout(timer); signal.removeEventListener('abort', cancel); cancellations.delete(cancel); }; + const cancel = () => { if (finished) return; finished = true; cleanup(); reject(aborted()); }; + const timer = setTimeout(() => { + if (finished) return; + if (disposed || signal.aborted) { cancel(); return; } + finished = true; cleanup(); resolve(); + }, 3000); + cancellations.add(cancel); + signal.addEventListener('abort', cancel, { once: true }); + if (disposed || signal.aborted) cancel(); + }); + } + async function showWeather(input: WeatherArguments, signal: AbortSignal): Promise<{ displayed: true }> { + active(signal); validateWeatherArguments(input); active(signal); + return { displayed: true }; + } + const tools = Object.freeze({ + get_weather: Object.freeze({ + description: 'Return simulated local weather readings for a location.', followUp: true, + parameters: Object.freeze({ type: 'object', additionalProperties: false, + required: Object.freeze(['location']), properties: Object.freeze({ location: Object.freeze({ type: 'string', minLength: 1 }) }) }), + async handler(input: LocationArguments, { signal }) { + active(signal); const location = text(argumentsRecord(input, ['location']), 'location'); + await Promise.resolve(); active(signal); + return { location, temperatureF: 68, conditions: 'Sunny', humidity: 55, windMph: 8 }; + }, + } satisfies FunctionTool), + slow_status_check: Object.freeze({ + description: 'Run a cancellable local status check lasting three seconds.', followUp: true, + parameters: Object.freeze({ type: 'object', additionalProperties: false, required: Object.freeze([]), + properties: Object.freeze({ label: Object.freeze({ type: 'string', minLength: 1, default: 'status check' }) }) }), + async handler(input: StatusArguments, { signal }): Promise<{ label: string; status: 'complete' }> { + active(signal); const record = argumentsRecord(input, ['label']); + const label = Object.hasOwn(record, 'label') ? text(record, 'label') : 'status check'; + active(signal); await delay(signal); active(signal); + return { label, status: 'complete' }; + }, + } satisfies FunctionTool), + weather_card: Object.freeze({ + description: 'Display complete simulated weather readings and allow an assistant follow-up.', + parameters: weatherParameters, followUp: true, + handler: (input: WeatherArguments, { signal }) => showWeather(input, signal), + } satisfies FunctionTool), + weather_snapshot: Object.freeze({ + description: 'Display a terminal simulated weather snapshot without an assistant follow-up.', + parameters: weatherParameters, followUp: false, + handler: (input: WeatherArguments, { signal }) => showWeather(input, signal), + } satisfies FunctionTool), + confirm_booking: Object.freeze({ + description: 'Ask for an explicit fictional booking decision. No real reservation is made.', followUp: true, + parameters: Object.freeze({ type: 'object', additionalProperties: false, required: Object.freeze(['summary']), + properties: Object.freeze({ summary: Object.freeze({ type: 'string', minLength: 1 }) }) }), + async handler(input: BookingArguments, { signal }) { + active(signal); const { summary } = validateBookingArguments(input); active(signal); + let identity: BookingInvocation | undefined; + try { identity = current(signal); } catch (error) { if (signal.aborted || disposed) throw aborted(); throw new Error('Booking invocation unavailable', { cause: error }); } + if (!identity || identity.summary !== summary || snapshot.bookings.some((entry) => sameInvocation(entry, identity))) { + throw new Error('Booking invocation unavailable'); + } + const captured = identity; + // This UUID is app control authority, never a native call/message/run identity. + const token = crypto.randomUUID(); + const row: BookingPanel = Object.freeze({ ...captured, token, status: 'pending' }); + const result = new Promise<{ confirmed: boolean }>((resolve, reject) => { + let finished = false; + const cleanup = () => { pending.delete(token); cancellations.delete(cancel); signal.removeEventListener('abort', cancel); }; + const cancel = () => { + if (finished) return; + finished = true; cleanup(); + publish(Object.freeze({ ...captured, token, status: 'aborted' })); reject(aborted()); + }; + pending.set(token, { identity: captured, signal, cancel, finish(decision) { + if (finished || disposed || signal.aborted) return false; + // Consume authority before notification; reentrant controls cannot win twice. + pending.delete(token); + publish(Object.freeze({ ...captured, token, status: 'resolved', confirmed: decision })); + if (finished || disposed || signal.aborted) { cancel(); return false; } + // Notifications may revoke the native call without aborting its signal yet. + let latest: BookingInvocation | undefined; + try { latest = current(signal); } catch { cancel(); return false; } + if (!latest || !sameInvocation(captured, latest)) { cancel(); return false; } + finished = true; cleanup(); resolve({ confirmed: decision }); return true; + } }); + cancellations.add(cancel); signal.addEventListener('abort', cancel, { once: true }); + publish(row); + if (disposed || signal.aborted) cancel(); + if (!finished) { + let latest: BookingInvocation | undefined; + try { latest = current(signal); } catch { cancel(); } + if (!latest || !sameInvocation(captured, latest)) cancel(); + } + }); + // Observe rejection immediately even when native consumers attach after publication. + void result.catch(() => undefined); + return result; + }, + } satisfies FunctionTool), + }); + return Object.freeze({ + tools, + getSnapshot: () => snapshot, + subscribe(notify: () => void) { + if (disposed) return () => undefined; + listeners.add(notify); return () => { listeners.delete(notify); }; + }, + respond(token: string, decision: boolean): boolean { + const request = pending.get(token); + if (!request || disposed || request.signal.aborted || typeof decision !== 'boolean') return false; + let latest: BookingInvocation | undefined; + try { latest = current(request.signal); } catch { request.cancel(); return false; } + if (!latest || !sameInvocation(request.identity, latest) || disposed || request.signal.aborted) { + request.cancel(); return false; + } + return request.finish(decision); + }, + dispose() { + if (disposed) return; + disposed = true; listeners.clear(); + for (const cancel of [...cancellations]) cancel(); + pending.clear(); + }, + }); +} diff --git a/cockpit/ag-ui/client-tools/react/src/tools.type-test.ts b/cockpit/ag-ui/client-tools/react/src/tools.type-test.ts new file mode 100644 index 000000000..ecefde333 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/tools.type-test.ts @@ -0,0 +1,44 @@ +import { createSession } from '@threadplane/ag-ui'; +import type { ClientToolCall } from '@threadplane/ag-ui'; +import type { ToolContracts } from '@threadplane/core/tools'; +import { createClientToolsController } from './tools'; + +/** Compile-only checks against the actual native generic declaration boundary. */ +export function checkToolDeclarations() { + const controller = createClientToolsController(() => undefined); + const session = createSession({ threadId: 'compile-only', url: 'http://127.0.0.1:1', interruptMode: 'native' }, controller.tools); + const calls = session.getSnapshot().clientTools.calls; + const typed: readonly ClientToolCall>[] = calls; + for (const call of typed) { + if (call.name === 'get_weather') { + const location: string | undefined = call.args?.location; + const temperature: number | undefined = call.result?.temperatureF; + void location; void temperature; + // @ts-expect-error Narrowed weather args do not acquire booking fields. + void call.args?.summary; + // @ts-expect-error Native observed business arguments are readonly. + if (call.args) call.args.location = 'changed'; + } + if (call.name === 'confirm_booking') { + const summary: string | undefined = call.args?.summary; + const confirmed: boolean | undefined = call.result?.confirmed; + void summary; void confirmed; + // @ts-expect-error Native result observations are readonly. + if (call.result) call.result.confirmed = true; + } + if (call.name === 'weather_snapshot' || call.name === 'weather_card') { + const displayed: true | undefined = call.result?.displayed; void displayed; + } + if (call.name === 'slow_status_check') { + const status: 'complete' | undefined = call.result?.status; void status; + } + // @ts-expect-error Native local observations retain readonly identity. + call.callId = 'changed'; + } + // @ts-expect-error Observed native calls cannot be appended. + calls.push({}); + // @ts-expect-error Heterogeneous handlers preserve their specific authored arguments. + void controller.tools.get_weather.handler({ summary: 'wrong' }, { signal: new AbortController().signal }); + // @ts-expect-error Booking execution receives only the real signal, never fabricated IDs. + void controller.tools.confirm_booking.handler({ summary: 'fictional' }, { signal: new AbortController().signal, callId: 'fake' }); +} diff --git a/cockpit/ag-ui/client-tools/react/src/weather-card.tsx b/cockpit/ag-ui/client-tools/react/src/weather-card.tsx new file mode 100644 index 000000000..e3fad2e45 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/src/weather-card.tsx @@ -0,0 +1,26 @@ +import type { WeatherArguments } from './tools'; +export function WeatherCard({ + reading, +}: { + readonly reading: Readonly; +}) { + return ( +
+

Simulated weather

+

{reading.location}

+
+
Temperature
+
{reading.temperatureF}°F
+
Conditions
+
{reading.conditions}
+
Humidity
+
{reading.humidity}%
+
Wind
+
{reading.windMph} mph
+
+
+ ); +} diff --git a/cockpit/ag-ui/client-tools/react/tsconfig.json b/cockpit/ag-ui/client-tools/react/tsconfig.json new file mode 100644 index 000000000..c28cb9363 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "noEmit": true, + "composite": false, + "emitDeclarationOnly": false, + "paths": { + "@threadplane/core": ["libs/core/src/index.ts"], + "@threadplane/core/tools": ["libs/core/src/tools/index.ts"], + "@threadplane/ag-ui": ["fixtures/react-parity/ag-ui-candidate/entry.ts"], + "@threadplane/react": ["libs/react/src/index.ts"], + "@threadplane/react/chat": ["libs/react/src/chat/index.ts"] + }, + "declarationMap": false + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/cockpit/ag-ui/client-tools/react/tsconfig.tools.json b/cockpit/ag-ui/client-tools/react/tsconfig.tools.json new file mode 100644 index 000000000..d878b410c --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/tsconfig.tools.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false, + "paths": { + "@threadplane/core": ["libs/core/src/index.ts"], + "@threadplane/core/tools": ["libs/core/src/tools/index.ts"], + "@threadplane/ag-ui": ["fixtures/react-parity/ag-ui-candidate/entry.ts"] + } + }, + "include": ["src/tools.ts", "src/tools.spec.ts", "src/tools.type-test.ts"] +} diff --git a/cockpit/ag-ui/client-tools/react/vite.config.mts b/cockpit/ag-ui/client-tools/react/vite.config.mts new file mode 100644 index 000000000..ea4011866 --- /dev/null +++ b/cockpit/ag-ui/client-tools/react/vite.config.mts @@ -0,0 +1,19 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + resolve: { + alias: { + '@threadplane/ag-ui': fileURLToPath(new URL( + '../../../../fixtures/react-parity/ag-ui-candidate/entry.ts', import.meta.url, + )), + }, + }, + test: { + environment: 'jsdom', + include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'], + maxWorkers: 2, + }, +}); diff --git a/fixtures/react-parity/native-ag-ui-client-tools/compiled_worker.py b/fixtures/react-parity/native-ag-ui-client-tools/compiled_worker.py index 2f95013a9..7670bf352 100644 --- a/fixtures/react-parity/native-ag-ui-client-tools/compiled_worker.py +++ b/fixtures/react-parity/native-ag-ui-client-tools/compiled_worker.py @@ -80,6 +80,68 @@ async def _astream(self, messages, stop=None, run_manager=None, **kwargs): yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-weather-call', content='', tool_call_chunks=[{'id': None, 'name': None, 'args': '"KASE"}', 'index': 0}])) yield ChatGenerationChunk(message=AIMessageChunk(id='scripted-weather-call', content='', response_metadata={'finish_reason': 'tool_calls'})) +class CockpitModel(ScriptedModel): + """History-driven BaseChatModel only; canonical graph/adapter author all events.""" + sequence: int = 0 + + def answer(self, messages): + self.sequence += 1 + identity = f'cockpit-assistant-{self.sequence}' + last = messages[-1] + if isinstance(last, ToolMessage): + latest_human = next((m for m in reversed(messages) if m.type == 'human'), None) + if latest_human and 'Rounds' in str(latest_human.content): + after_human = messages[messages.index(latest_human) + 1:] + if len([m for m in after_human if isinstance(m, ToolMessage)]) == 1: + return AIMessage(id=identity, content='', tool_calls=[{'id': f'cockpit-call-{self.sequence}-0', 'name': 'weather_card', 'args': {'location': 'San Francisco', 'temperatureF': 68, 'conditions': 'Sunny', 'humidity': 55, 'windMph': 8}, 'type': 'tool_call'}]) + receipts = [m.content for m in messages if isinstance(m, ToolMessage)] + return AIMessage(id=identity, content='Canonical local results received: ' + json.dumps(receipts)) + text = str(last.content) + reading = {'location': 'San Francisco', 'temperatureF': 68, 'conditions': 'Sunny', 'humidity': 55, 'windMph': 8} + if 'Literal' in text: + reading['location'] = '' + if 'Malformed' in text: + names = [('weather_card', {**reading, 'humidity': 101})] + elif 'Unknown' in text: + names = [('unknown_weather', {})] + elif 'Mixed' in text: + names = [('weather_snapshot', reading), ('get_weather', {'location': 'San Francisco'})] + elif 'Bookings' in text: + names = [('confirm_booking', {'summary': 'Fictional dinner A'}), ('confirm_booking', {'summary': 'Fictional dinner B'})] + elif 'confirm_booking' in text: + names = [('confirm_booking', {'summary': 'Fictional dinner booking'})] + elif 'slow_status_check' in text: + names = [('slow_status_check', {'label': 'demo check'})] + elif 'weather_snapshot' in text: + names = [('weather_snapshot', reading)] + elif 'weather_card' in text: + names = [('weather_card', reading)] + elif 'get_weather' in text: + names = [('get_weather', {'location': 'San Francisco'})] + else: + return AIMessage(id=identity, content='Canonical plain answer: ' + text) + return AIMessage(id=identity, content='', tool_calls=[{'id': f'cockpit-call-{self.sequence}-{index}', 'name': name, 'args': args, 'type': 'tool_call'} for index, (name, args) in enumerate(names)]) + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + self.observed_messages.append([m.model_dump(mode='json') for m in messages]) + return ChatResult(generations=[ChatGeneration(message=self.answer(messages))]) + +class CockpitStreamingModel(CockpitModel): + streaming: bool = False + async def _astream(self, messages, stop=None, run_manager=None, **kwargs): + self.observed_messages.append([m.model_dump(mode='json') for m in messages]) + answer = self.answer(messages) + if not answer.tool_calls: + yield ChatGenerationChunk(message=AIMessageChunk(id=answer.id, content=answer.content)) + return + for index, call in enumerate(answer.tool_calls): + arguments = json.dumps(call['args']) + split = max(1, len(arguments) // 2) + yield ChatGenerationChunk(message=AIMessageChunk(id=answer.id, content='', tool_call_chunks=[{'id': call['id'], 'name': call['name'], 'args': '', 'index': index}])) + yield ChatGenerationChunk(message=AIMessageChunk(id=answer.id, content='', tool_call_chunks=[{'id': None, 'name': None, 'args': arguments[:split], 'index': index}])) + yield ChatGenerationChunk(message=AIMessageChunk(id=answer.id, content='', tool_call_chunks=[{'id': None, 'name': None, 'args': arguments[split:], 'index': index}])) + yield ChatGenerationChunk(message=AIMessageChunk(id=answer.id, content='', response_metadata={'finish_reason': 'tool_calls'})) + def emit(value): encoded = json.dumps(value, separators=(',', ':')) if len(encoded.encode()) > MAX_BYTES: @@ -92,13 +154,17 @@ async def main(): assert mode in ['nonstream', 'stream', 'unknown'] module = importlib.import_module('src.graph') model = ScriptedStreamingModel() if mode == 'stream' else ScriptedModel(call_name='unknown_weather' if mode == 'unknown' else 'get_weather') + scenario = sys.argv[2] if len(sys.argv) > 2 else None + assert scenario in [None, 'cockpit'] + if scenario == 'cockpit': + model = CockpitStreamingModel(streaming=True) if mode == 'stream' else CockpitModel() module._base_llm = model graph = module.build_client_tools_graph() agent = LangGraphAgent(name='offline-canonical-client-tools', graph=graph) paths = [Path(__file__), ROOT / 'cockpit/ag-ui/client-tools/python/src/graph.py', ROOT / 'cockpit/ag-ui/client-tools/python/prompts/client-tools.md', ROOT / 'cockpit/ag-ui/client-tools/python/uv.lock', ROOT / 'cockpit/ag-ui/client-tools/python/pyproject.toml'] modules = {name: str(Path(sys.modules[name].__file__).resolve()) for name in ['src.graph', 'ag_ui_langgraph.agent', 'threadplane.middleware.langgraph.middleware']} paths.extend(Path(value) for value in modules.values()) - identity = {'pid': os.getpid(), 'python': sys.version.split()[0], 'mode': mode, 'modules': modules, 'socketAttempts': socket_attempts, 'blockedSocketConstructions': blocked_socket_constructions, 'versions': {name: importlib.metadata.version(name) for name in ['ag-ui-langgraph', 'ag-ui-protocol', 'langgraph', 'langchain-core']}, 'sources': [{'path': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in dict.fromkeys(paths)]} + identity = {'pid': os.getpid(), 'python': sys.version.split()[0], 'mode': mode, 'scenario': scenario, 'environment': sys.prefix, 'modules': modules, 'socketAttempts': socket_attempts, 'blockedSocketConstructions': blocked_socket_constructions, 'versions': {name: importlib.metadata.version(name) for name in ['ag-ui-langgraph', 'ag-ui-protocol', 'langgraph', 'langchain-core']}, 'sources': [{'path': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in dict.fromkeys(paths)]} emit({'ready': identity}) count = 0 while True: diff --git a/fixtures/react-parity/native-ag-ui-client-tools/server.mjs b/fixtures/react-parity/native-ag-ui-client-tools/server.mjs index 686830191..7e41136ff 100644 --- a/fixtures/react-parity/native-ag-ui-client-tools/server.mjs +++ b/fixtures/react-parity/native-ag-ui-client-tools/server.mjs @@ -86,11 +86,17 @@ export async function createClientToolsServer({ root = defaultRoot, mode = 'nonstream', holdTerminal = false, + scenario, + holdAfterRequest = 0, + holdReceipt = false, environment = process.env.THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT ?? '/tmp/threadplane-agui-client-tools-native-fixture-env', } = {}) { root = realpathSync(root); assert.ok(['nonstream', 'stream', 'unknown'].includes(mode)); + assert.ok(scenario === undefined || scenario === 'cockpit'); + assert.equal(typeof holdReceipt, 'boolean'); + assert.ok(Number.isSafeInteger(holdAfterRequest) && holdAfterRequest >= 0 && holdAfterRequest < 12); const env = Object.fromEntries( Object.entries(process.env).filter( ([key]) => !/(?:API_KEY|TOKEN|SECRET|LANGSMITH|LANGCHAIN)/i.test(key) @@ -117,6 +123,7 @@ export async function createClientToolsServer({ 'python', worker, mode, + ...(scenario ? [scenario] : []), ], { cwd: root, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true } ); @@ -142,7 +149,7 @@ export async function createClientToolsServer({ stdinClosed: false, }; const stats = () => - structuredClone({ ready, requests: records, errors, stderr, closure }); + structuredClone({ ready, requests: records, errors, stderr, closure, ownership: { spawnedPid: child.pid, port, processes: [...ownedProcesses.values()] } }); const fail = (error) => { errors.push(String(error)); for (const pending of waiting.splice(0)) pending.reject(error); @@ -211,9 +218,10 @@ export async function createClientToolsServer({ if (request.method === 'POST' && request.url === '/release') { for (const record of records) if (record.held && !record.closed) { - record.response.write( - `data: ${JSON.stringify(record.events.at(-1))}\n\n` - ); + for (const event of record.heldEvents) { + record.response.write(`data: ${JSON.stringify(event)}\n\n`); + record.sentEvents.push(event); + } record.response.end(); record.held = false; } @@ -226,7 +234,7 @@ export async function createClientToolsServer({ 'bounded sequential requests' ); active = true; - const record = { closed: false, events: [], held: false }; + const record = { closed: false, events: [], sentEvents: [], heldEvents: [], held: false }; records.push(record); // The response itself is non-enumerable: stats retain only independent evidence. Object.defineProperty(record, 'response', { value: response }); @@ -250,12 +258,17 @@ export async function createClientToolsServer({ 'content-type': 'text/event-stream', 'cache-control': 'no-store', }); - record.held = holdTerminal; - const events = holdTerminal ? output.events.slice(0, -1) : output.events; + record.held = holdTerminal && records.length > holdAfterRequest; + const receiptAt = holdReceipt ? output.events.findLastIndex((event) => event.type === 'MESSAGES_SNAPSHOT') : -1; + const heldAt = record.held ? (receiptAt >= 0 ? receiptAt : output.events.length - 1) : output.events.length; + record.heldEvents = output.events.slice(heldAt); + const events = output.events.slice(0, heldAt); for (const event of events) - if (!response.destroyed) + if (!response.destroyed) { response.write(`data: ${JSON.stringify(event)}\n\n`); - if (!holdTerminal) response.end(); + record.sentEvents.push(event); + } + if (!record.held) response.end(); } catch (error) { errors.push(String(error)); if (!response.headersSent) json(422, { error: String(error) }); @@ -355,6 +368,8 @@ export async function createClientToolsServer({ } ownedGroupMembers(inventory, child.pid, ownedProcesses); assert.equal(ready.mode, mode); + assert.equal(ready.scenario, scenario ?? null); + assert.equal(realpathSync(ready.environment), realpathSync(environment)); assert.match(ready.python, /^3\.12\./); assert.equal(ready.versions['ag-ui-langgraph'], '0.0.45'); assert.equal(ready.versions['ag-ui-protocol'], '1.0.0'); diff --git a/libs/cockpit-registry/src/lib/capability-registry.ts b/libs/cockpit-registry/src/lib/capability-registry.ts index 51434db5f..01ad120bd 100644 --- a/libs/cockpit-registry/src/lib/capability-registry.ts +++ b/libs/cockpit-registry/src/lib/capability-registry.ts @@ -85,7 +85,7 @@ export const capabilities: readonly Capability[] = [ { id: 'ag-ui-streaming', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'streaming', angularProject: 'cockpit-ag-ui-streaming-angular', port: 4321, react: { project: 'cockpit-ag-ui-streaming-react', port: 4609 }, pythonPort: 5321, pythonDir: 'cockpit/ag-ui/streaming/python' }, { id: 'ag-ui-tool-views', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'tool-views', angularProject: 'cockpit-ag-ui-tool-views-angular', port: 4322, react: { project: 'cockpit-ag-ui-tool-views-react', port: 4611 }, pythonPort: 5322, pythonDir: 'cockpit/ag-ui/tool-views/python' }, { id: 'ag-ui-json-render', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'json-render', angularProject: 'cockpit-ag-ui-json-render-angular', port: 4323, react: { project: 'cockpit-ag-ui-json-render-react', port: 4612 }, pythonPort: 5323, pythonDir: 'cockpit/ag-ui/json-render/python' }, - { id: 'ag-ui-client-tools', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'client-tools', angularProject: 'cockpit-ag-ui-client-tools-angular', port: 4325, pythonPort: 5325, pythonDir: 'cockpit/ag-ui/client-tools/python' }, + { id: 'ag-ui-client-tools', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'client-tools', angularProject: 'cockpit-ag-ui-client-tools-angular', port: 4325, react: { project: 'cockpit-ag-ui-client-tools-react', port: 4640 }, pythonPort: 5325, pythonDir: 'cockpit/ag-ui/client-tools/python' }, { id: 'ag-ui-a2ui', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'a2ui', angularProject: 'cockpit-ag-ui-a2ui-angular', port: 4324, pythonPort: 5324, pythonDir: 'cockpit/ag-ui/a2ui/python' }, { id: 'ag-ui-subagents', runtimeAdapter: 'ag-ui', product: 'ag-ui', topic: 'subagents', angularProject: 'cockpit-ag-ui-subagents-angular', port: 4326, react: { project: 'cockpit-ag-ui-subagents-react', port: 4613 }, pythonPort: 5326, pythonDir: 'cockpit/ag-ui/subagents/python' }, // Runtime-portability examples (one capability, many runtimes; AG-UI-served diff --git a/libs/cockpit-registry/src/lib/content-descriptors.ts b/libs/cockpit-registry/src/lib/content-descriptors.ts index d95677bc1..8651ab115 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.ts @@ -1149,6 +1149,22 @@ const agUiToolViewsReact = (() => { }); })(); +const agUiClientToolsReact = (() => { + const canonical = capabilityModules.find(descriptor => descriptor.id === 'ag-ui-client-tools-python'); + const frontend = getCockpitFrontends().find(entry => entry.project === 'cockpit-ag-ui-client-tools-react'); + if (!canonical || !frontend) throw new Error('AG-UI Client Tools frontend registration is missing'); + return freezeCapabilityDescriptor({ + ...canonical, id: 'ag-ui-client-tools-python-react', frontend: 'react', + title: 'AG-UI Client Tools (React preview)', + codeAssetPaths: [ + ...['app.tsx', 'tools.ts', 'application.ts', 'projection.ts', 'weather-card.tsx', 'confirm-booking.tsx', 'connection.ts', 'main.tsx', 'styles.css'].map(file => `cockpit/ag-ui/client-tools/react/src/${file}`), + 'cockpit/ag-ui/client-tools/react/project.json', + ...canonical.promptAssetPaths, + ], + runtimeUrl: frontend.runtimePath, devPort: frontend.port, + }); +})(); + const agUiJsonRenderReact = (() => { const canonical=capabilityModules.find(descriptor=>descriptor.id==='ag-ui-json-render-python'); const frontend=getCockpitFrontends().find(entry=>entry.project==='cockpit-ag-ui-json-render-react'); @@ -1366,6 +1382,7 @@ export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = agUiStreamingReact, agUiInterruptsReact, agUiToolViewsReact, + agUiClientToolsReact, agUiJsonRenderReact, agUiSubagentsReact, renderSpecReact, diff --git a/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts b/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts index 09ef17aad..0c7a114f2 100644 --- a/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-descriptors.spec.ts @@ -14,7 +14,7 @@ it('keeps Subagents as one canonical backend with Angular default and two exact expect(subagents[0]).toMatchObject({ angularProject: 'cockpit-deep-agents-subagents-angular', pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'subagents', pythonPort: 5312 }); const frontends = getCockpitFrontends(); expect(frontends.filter(entry => entry.frontend === 'angular')).toHaveLength(41); - expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(33); + expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(34); expect(frontends.filter(entry => entry.product === 'deep-agents' && entry.topic === 'subagents')).toEqual([ { frontend: 'angular', project: 'cockpit-deep-agents-subagents-angular', port: 4312, product: 'deep-agents', topic: 'subagents', runtimePath: 'deep-agents/subagents', buildOutput: 'dist/cockpit/deep-agents/subagents/angular' }, { frontend: 'react', project: 'cockpit-deep-agents-subagents-react', port: 4630, product: 'deep-agents', topic: 'subagents', runtimePath: 'deep-agents/subagents/react', buildOutput: 'dist/cockpit/deep-agents/subagents/react' }, @@ -27,7 +27,7 @@ it('keeps Planning as one canonical backend with Angular default and two exact f expect(planning[0]).toMatchObject({ angularProject: 'cockpit-deep-agents-planning-angular', pythonDir: 'cockpit/deep-agents/planning/python', graphName: 'da-planning' }); const frontends = getCockpitFrontends(); expect(frontends.filter(entry => entry.frontend === 'angular')).toHaveLength(41); - expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(33); + expect(frontends.filter(entry => entry.frontend === 'react')).toHaveLength(34); expect(frontends.filter(entry => entry.product === 'deep-agents' && entry.topic === 'planning')).toEqual([ { frontend: 'angular', project: 'cockpit-deep-agents-planning-angular', port: 4310, product: 'deep-agents', topic: 'planning', runtimePath: 'deep-agents/planning', buildOutput: 'dist/cockpit/deep-agents/planning/angular' }, { frontend: 'react', project: 'cockpit-deep-agents-planning-react', port: 4628, product: 'deep-agents', topic: 'planning', runtimePath: 'deep-agents/planning/react', buildOutput: 'dist/cockpit/deep-agents/planning/react' }, diff --git a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts index fb7837e87..a2359976d 100644 --- a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts @@ -267,8 +267,8 @@ describe('registered React public previews', () => { expect(react?.backendAssetPaths).toEqual(['graph.py','dashboard_tools.py','server.py'].map(file=>`cockpit/ag-ui/json-render/python/src/${file}`)); expect(getFrontendCapabilityDescriptor(identity,'angular')?.runtimeUrl).toBe('ag-ui/json-render'); expect(getFrontendCapabilityDescriptor({...identity,topic:'testing'},'react')).toBeUndefined(); - expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(33); - expect(getCockpitFrontends()).toHaveLength(74); + expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(34); + expect(getCockpitFrontends()).toHaveLength(75); for(const asset of [...(react?.codeAssetPaths??[]),...(react?.backendAssetPaths??[])])expect(existsSync(new URL('../../../../'+asset,import.meta.url))).toBe(true); }); it('resolves AG-UI Tool Views with authored weather policy and unchanged server-tool endpoint', () => { @@ -297,9 +297,9 @@ describe('registered React public previews', () => { expect(getFrontendCapabilityDescriptor(identity, 'angular')?.runtimeUrl).toBe('ag-ui/interrupts'); expect(getFrontendCapabilityDescriptor({ ...identity, product: 'langgraph' }, 'react')?.runtimeUrl).toBe('langgraph/interrupts/react'); const frontends = getCockpitFrontends(); - expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(33); - expect(frontends).toHaveLength(74); - expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(74); + expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(34); + expect(frontends).toHaveLength(75); + expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(75); for (const asset of [...(react?.codeAssetPaths ?? []), ...(react?.backendAssetPaths ?? [])]) expect(existsSync(new URL('../../../../' + asset, import.meta.url))).toBe(true); }); @@ -314,8 +314,8 @@ describe('registered React public previews', () => { expect(react?.backendAssetPaths).toContain('cockpit/ag-ui/streaming/python/src/server.py'); expect(getFrontendCapabilityDescriptor(identity,'angular')?.runtimeUrl).toBe('ag-ui/streaming'); expect(getFrontendCapabilityDescriptor(streaming,'react')?.runtimeUrl).toBe('langgraph/streaming/react'); - expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(33); - expect(getCockpitFrontends()).toHaveLength(74); + expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(34); + expect(getCockpitFrontends()).toHaveLength(75); }); it('maps the canonical Deployment guide to the exact Deployment Runtime React sources', () => { const identity = { ...streaming, topic: 'deployment-runtime' }; @@ -343,8 +343,8 @@ describe('registered React public previews', () => { ).toBe('langgraph/deployment-runtime'); expect( getCockpitFrontends().filter((frontend) => frontend.frontend === 'react') - ).toHaveLength(33); - expect(getCockpitFrontends()).toHaveLength(74); + ).toHaveLength(34); + expect(getCockpitFrontends()).toHaveLength(75); }); it('resolves Time Travel with exact owner, checkpoint eligibility and canonical identity sources', () => { const identity = { ...streaming, topic: 'time-travel' }; @@ -604,11 +604,11 @@ describe('registered React public previews', () => { it('enumerates deployable frontend identities with distinct paths and projects', () => { const frontends = getCockpitFrontends(); - expect(frontends).toHaveLength(74); - expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(74); - expect(new Set(frontends.map((entry) => entry.project)).size).toBe(74); - expect(new Set(frontends.map((entry) => entry.port)).size).toBe(74); - expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(74); + expect(frontends).toHaveLength(75); + expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(75); + expect(new Set(frontends.map((entry) => entry.project)).size).toBe(75); + expect(new Set(frontends.map((entry) => entry.port)).size).toBe(75); + expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(75); expect( frontends.find( (entry) => entry.project === 'cockpit-langgraph-memory-react' @@ -639,3 +639,21 @@ describe('registered React public previews', () => { ); }); }); + + it('resolves AG-UI Client Tools React from one native backend while retaining Angular default', () => { + const canonical = capabilityModules.find(entry => entry.id === 'ag-ui-client-tools-python'); + if (!canonical) throw Error('Missing canonical AG-UI Client Tools'); + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as CockpitManifestIdentity, 'react'); + expect(react).toMatchObject({ id: 'ag-ui-client-tools-python-react', frontend: 'react', runtimeAdapter: 'ag-ui', runtimeUrl: 'ag-ui/client-tools/react', devPort: 4640, docsPath: '/docs/ag-ui/guides/client-tools', backendAssetPaths: canonical.backendAssetPaths, promptAssetPaths: canonical.promptAssetPaths }); + expect(react?.codeAssetPaths).toEqual([ + ...['app.tsx', 'tools.ts', 'application.ts', 'projection.ts', 'weather-card.tsx', 'confirm-booking.tsx', 'connection.ts', 'main.tsx', 'styles.css'].map(file => `cockpit/ag-ui/client-tools/react/src/${file}`), + 'cockpit/ag-ui/client-tools/react/project.json', + 'cockpit/ag-ui/client-tools/python/prompts/client-tools.md', + ]); + for (const asset of react?.codeAssetPaths ?? []) expect(existsSync(new URL('../../../../'+asset, import.meta.url))).toBe(true); + expect(getFrontendCapabilityDescriptor(canonical.manifestIdentity as CockpitManifestIdentity, 'angular')).toBe(canonical); + expect(getCockpitFrontends().filter(entry => entry.product === 'ag-ui' && entry.topic === 'client-tools')).toEqual([ + { frontend: 'angular', project: 'cockpit-ag-ui-client-tools-angular', port: 4325, product: 'ag-ui', topic: 'client-tools', runtimePath: 'ag-ui/client-tools', buildOutput: 'dist/cockpit/ag-ui/client-tools/angular' }, + { frontend: 'react', project: 'cockpit-ag-ui-client-tools-react', port: 4640, product: 'ag-ui', topic: 'client-tools', runtimePath: 'ag-ui/client-tools/react', buildOutput: 'dist/cockpit/ag-ui/client-tools/react' }, + ]); +}); diff --git a/libs/workspace-react/src/lib/components/code-mode/code-mode.spec.tsx b/libs/workspace-react/src/lib/components/code-mode/code-mode.spec.tsx index 97634d2e7..deeaf8c40 100644 --- a/libs/workspace-react/src/lib/components/code-mode/code-mode.spec.tsx +++ b/libs/workspace-react/src/lib/components/code-mode/code-mode.spec.tsx @@ -17,6 +17,16 @@ describe('CodeMode', () => { container?.remove(); }); + it('renders one tree row and tab when a prompt is also an actual code include', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => root!.render(actual prompt'}} promptFiles={{'prompts/client-tools.md': 'actual prompt'}} />)); + expect(container.querySelectorAll('[data-file-row]')).toHaveLength(1); + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(1); + expect(container.textContent).toContain('actual prompt'); + }); + it('renders Shiki-highlighted HTML for the active file', () => { container = document.createElement('div'); document.body.appendChild(container); diff --git a/libs/workspace-react/src/lib/components/code-mode/code-mode.tsx b/libs/workspace-react/src/lib/components/code-mode/code-mode.tsx index af075a99e..195043125 100644 --- a/libs/workspace-react/src/lib/components/code-mode/code-mode.tsx +++ b/libs/workspace-react/src/lib/components/code-mode/code-mode.tsx @@ -31,7 +31,7 @@ function CodeFileContent({ export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFiles, promptFiles, capability }: CodeModeProps) { const promptPaths = React.useMemo(() => Object.keys(promptFiles), [promptFiles]); const allPaths = React.useMemo( - () => [...codeAssetPaths, ...backendAssetPaths, ...promptPaths], + () => [...new Set([...codeAssetPaths, ...backendAssetPaths, ...promptPaths])], [codeAssetPaths, backendAssetPaths, promptPaths], ); diff --git a/scripts/cockpit-frontend-matrix.spec.mjs b/scripts/cockpit-frontend-matrix.spec.mjs index 953ae601b..7f1928fb5 100644 --- a/scripts/cockpit-frontend-matrix.spec.mjs +++ b/scripts/cockpit-frontend-matrix.spec.mjs @@ -12,7 +12,7 @@ test('Memory backend changes select its two actual registered frontends and exac assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-deep-agents-memory-angular', 'cockpit-deep-agents-memory-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/deep-agents/memory/python' && cap.pythonName === 'cockpit-deep-agents-memory-python')); const registry = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); const registry = module.default ?? module; console.log(JSON.stringify({ frontends: registry.getCockpitFrontends(), capability: registry.capabilities.find(cap => cap.id === "da-memory") }));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(registry.frontends.length, 74); + assert.equal(registry.frontends.length, 75); assert.deepEqual(registry.frontends.filter(frontend => frontend.product === 'deep-agents' && frontend.topic === 'memory'), [ { frontend: 'angular', project: 'cockpit-deep-agents-memory-angular', port: 4313, product: 'deep-agents', topic: 'memory', runtimePath: 'deep-agents/memory', buildOutput: 'dist/cockpit/deep-agents/memory/angular' }, { frontend: 'react', project: 'cockpit-deep-agents-memory-react', port: 4632, product: 'deep-agents', topic: 'memory', runtimePath: 'deep-agents/memory/react', buildOutput: 'dist/cockpit/deep-agents/memory/react' }, @@ -79,7 +79,7 @@ test('Generative UI backend changes select both canonical frontends registered b assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-chat-generative-ui-angular', 'cockpit-chat-generative-ui-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/chat/generative-ui/python')); const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(frontends.length, 74); + assert.equal(frontends.length, 75); assert.deepEqual(frontends.filter(frontend => frontend.project.startsWith('cockpit-chat-generative-ui-')).map(frontend => frontend.project).sort(), selected.map(cap => cap.angular).sort()); }); test('Timeline backend changes select both canonical frontends registered by the website', () => { @@ -88,7 +88,7 @@ test('Timeline backend changes select both canonical frontends registered by the assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-chat-timeline-angular', 'cockpit-chat-timeline-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/chat/timeline/python')); const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(frontends.length, 74); + assert.equal(frontends.length, 75); assert.deepEqual(frontends.filter(frontend => frontend.project.startsWith('cockpit-chat-timeline-')).map(frontend => frontend.project).sort(), selected.map(cap => cap.angular).sort()); }); test('Chat Timeline owns an exact native React identity', async () => { @@ -107,7 +107,7 @@ test('Threads backend changes select both canonical frontends registered by the assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-chat-threads-angular', 'cockpit-chat-threads-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/chat/threads/python')); const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); - assert.equal(frontends.length, 74); + assert.equal(frontends.length, 75); assert.deepEqual(frontends.filter(frontend => frontend.project.startsWith('cockpit-chat-threads-')).map(frontend => frontend.project).sort(), selected.map(cap => cap.angular).sort()); }); test('Chat Threads owns an exact native React identity', async () => { @@ -585,3 +585,15 @@ test('native chat generative UI has an exact isolated key and route', async () = assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-deep-agents-skills-angular', 'cockpit-deep-agents-skills-react']); assert.ok(selected.every(cap => cap.python === 'cockpit/deep-agents/skills/python')); }); + +test('AG-UI Client Tools selects the two native frontends and one canonical backend', () => { + const root = fileURLToPath(new URL('..', import.meta.url)); + const selected = selectCockpitCaps(deriveCockpitCaps(root), new Set(['cockpit-ag-ui-client-tools-python']), { fullFleet: false }); + assert.deepEqual(selected.map(cap => cap.angular).sort(), ['cockpit-ag-ui-client-tools-angular', 'cockpit-ag-ui-client-tools-react']); + assert.ok(selected.every(cap => cap.python === 'cockpit/ag-ui/client-tools/python')); + const frontends = JSON.parse(execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', 'const module = await import("./libs/cockpit-registry/src/lib/capability-registry.ts"); console.log(JSON.stringify((module.default ?? module).getCockpitFrontends()));'], { cwd: root, encoding: 'utf8', env: { ...process.env, TSX_TSCONFIG_PATH: 'tsconfig.base.json' } })); + assert.equal(frontends.length, 75); + assert.equal(frontends.filter(cap => cap.frontend === 'react').length, 34); + assert.equal(frontends.filter(cap => cap.frontend === 'angular').length, 41); + assert.equal(frontends.find(cap => cap.project === 'cockpit-ag-ui-client-tools-react')?.runtimePath, 'ag-ui/client-tools/react'); +}); diff --git a/scripts/react-cockpit/ag-ui-client-tools-configuration.spec.mjs b/scripts/react-cockpit/ag-ui-client-tools-configuration.spec.mjs new file mode 100644 index 000000000..d7af71476 --- /dev/null +++ b/scripts/react-cockpit/ag-ui-client-tools-configuration.spec.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { reactCockpitConfiguration } from './configuration.mjs'; +test('strict authored native client-tools entry is separate from LangGraph with owned unused port', () => { + const entry = reactCockpitConfiguration('ag-ui-client-tools'); + assert.deepEqual(entry, { + topic: 'client-tools', + adapter: 'ag-ui', + appPath: 'cockpit/ag-ui/client-tools/react', + base: '/ag-ui/client-tools/react/', + port: 4640, + project: 'cockpit-ag-ui-client-tools-react', + }); + assert.equal( + reactCockpitConfiguration('client-tools').appPath, + 'cockpit/langgraph/client-tools/react' + ); + assert.throws( + () => reactCockpitConfiguration('ag-ui-client-tools/../other'), + /Unsupported/ + ); + assert.ok(Object.isFrozen(entry)); +}); diff --git a/scripts/react-cockpit/ag-ui-client-tools-fixture.mjs b/scripts/react-cockpit/ag-ui-client-tools-fixture.mjs new file mode 100644 index 000000000..ba7896d17 --- /dev/null +++ b/scripts/react-cockpit/ag-ui-client-tools-fixture.mjs @@ -0,0 +1,71 @@ +/** Offline browser proof only. Every positive event comes from the canonical worker. */ +import assert from 'node:assert/strict'; +import { request as httpRequest } from 'node:http'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createClientToolsServer } from '../../fixtures/react-parity/native-ag-ui-client-tools/server.mjs'; +const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +export function createAgUiClientToolsFixture({ root = defaultRoot } = {}) { + const environment = resolve(root, 'cockpit/ag-ui/client-tools/python/.venv'); + let owner, closed = false, operations = Promise.resolve(); + const receipts = []; + const serialize = (operation) => { + const result = operations.then(operation); + operations = result.catch(() => undefined); + return result; + }; + async function closeOwner() { + const previous = owner; + owner = undefined; + if (!previous) return; + await previous.close(); + const receipt = previous.stats(); + assert.ok(Object.values(receipt.closure).every(Boolean)); + receipts.push(receipt); + } + const reset = (options = {}) => serialize(async () => { + assert.ok(!closed, 'Fixture closed'); + await closeOwner(); + assert.ok(['nonstream', 'stream', 'unknown'].includes(options.mode ?? 'nonstream')); + assert.ok(options.holdTerminal === undefined || typeof options.holdTerminal === 'boolean'); + owner = await createClientToolsServer({ root, environment, scenario: 'cockpit', mode: options.mode ?? 'nonstream', holdTerminal: options.holdTerminal ?? false, holdAfterRequest: options.holdAfterRequest ?? 0, holdReceipt: options.holdReceipt ?? false }); + }); + const stats = () => structuredClone({ current: owner?.stats(), closed: receipts }); + const json = (response, value, status = 200) => response.writeHead(status, {'content-type':'application/json'}).end(JSON.stringify(value)); + async function handle(request, response, pathname) { + if (!['/__reset', '/__stats', '/__requests', '/__release', '/__close', '/__finish', '/ag-ui/client-tools/agent'].includes(pathname)) return false; + try { + if (pathname === '/__stats') { json(response, stats()); return true; } + if (pathname === '/__requests') { json(response, owner?.stats().requests ?? []); return true; } + if (pathname === '/__finish') { await serialize(closeOwner); json(response, stats()); return true; } + if (pathname === '/__close') { await close(); json(response, stats()); return true; } + if (pathname === '/__reset') { + assert.equal(request.method, 'POST'); + let body = ''; + for await (const chunk of request) { body += chunk; assert.ok(body.length <= 2000000); } + await reset(body ? JSON.parse(body) : {}); + json(response, stats()); return true; + } + const current = owner; + assert.ok(current && !closed, 'Explicit prepared fixture reset required'); + if (pathname === '/__release') { + await fetch(current.url + '/release', {method:'POST'}); + json(response, {}); return true; + } + assert.equal(request.method, 'POST'); + // Preserve SSE timing and cancellation; never synthesize a positive event. + const upstream = httpRequest(current.url + '/agent', {method: 'POST', headers: {'content-type': 'application/json'}}, incoming => { + if (owner !== current || closed || response.destroyed) { incoming.destroy(); return; } + response.writeHead(incoming.statusCode, {'content-type': incoming.headers['content-type'] ?? 'application/json', 'cache-control': 'no-store'}); + incoming.pipe(response); + incoming.on('error', () => response.destroy()); + }); + upstream.on('error', () => { if (!response.headersSent && !response.destroyed) json(response, {error:'Canonical fixture unavailable'}, 502); else response.destroy(); }); + response.once('close', () => upstream.destroy()); + request.pipe(upstream); + return true; + } catch { if (!response.headersSent) json(response, {error:'Canonical fixture request failed'}, 500); else response.destroy(); return true; } + } + function close() { closed = true; return serialize(closeOwner); } + return Object.assign(handle, { reset, stats, close }); +} diff --git a/scripts/react-cockpit/ag-ui-client-tools-fixture.spec.mjs b/scripts/react-cockpit/ag-ui-client-tools-fixture.spec.mjs new file mode 100644 index 000000000..be15fe265 --- /dev/null +++ b/scripts/react-cockpit/ag-ui-client-tools-fixture.spec.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolve } from 'node:path'; +import { createClientToolsServer } from '../../fixtures/react-parity/native-ag-ui-client-tools/server.mjs'; +const root = process.cwd(); +const environment = resolve(root, 'cockpit/ag-ui/client-tools/python/.venv'); +const tools = ['get_weather', 'slow_status_check', 'weather_card', 'weather_snapshot', 'confirm_booking'].map(name => ({name, description: name, parameters: {type: 'object'}})); +function input(text, runId = 'run-1', messages) { + return { threadId: 'canonical-cockpit-thread', runId, state: {}, messages: messages ?? [{id: 'human-'+runId, role: 'user', content: text}], tools, context: [], forwardedProps: {} }; +} +test('cockpit scenario chooses actual human request and unique calls in retained canonical graph', {timeout: 30000}, async () => { + const server = await createClientToolsServer({root, environment, scenario: 'cockpit'}); + try { + const first = input('Show weather_snapshot'); + const response = await fetch(server.url+'/agent', {method:'POST', body: JSON.stringify(first)}); + assert.equal(response.status, 200); await response.text(); + const record = server.stats().requests[0]; + const call = record.events.filter(event => event.type === 'MESSAGES_SNAPSHOT').at(-1).messages.find(message => message.toolCalls?.length).toolCalls[0]; + assert.equal(call.function.name, 'weather_snapshot'); + assert.equal(record.observations.modelMessages[0].filter(m => m.type === 'human').length, 1); + assert.equal(record.observations.modelCatalogs[0].length, 5); + assert.deepEqual(record.observations.socketAttempts, []); + } finally { await server.close(); assert.ok(Object.values(server.stats().closure).every(Boolean)); } +}); +test('native wrapper reset closes exact prior owner and retains independent closure receipt', {timeout: 30000}, async () => { + const { createAgUiClientToolsFixture } = await import('./ag-ui-client-tools-fixture.mjs'); + const fixture = createAgUiClientToolsFixture({root}); + try { + await fixture.reset(); + const first = fixture.stats().current.ready.pid; + await fixture.reset({mode: 'stream', holdTerminal: true}); + assert.notEqual(fixture.stats().current.ready.pid, first); + assert.equal(fixture.stats().closed.length, 1); + assert.ok(Object.values(fixture.stats().closed[0].closure).every(Boolean)); + assert.equal(fixture.stats().current.ready.mode, 'stream'); + assert.deepEqual(fixture.stats().current.requests, []); + } finally { await fixture.close(); } + assert.ok(Object.values(fixture.stats().closed.at(-1).closure).every(Boolean)); + await assert.rejects(fixture.reset(), /closed/); +}); +test('concurrent resets serialize and shutdown revokes queued startup without leaking an owner', {timeout: 30000}, async () => { + const { createAgUiClientToolsFixture } = await import('./ag-ui-client-tools-fixture.mjs'); + const fixture=createAgUiClientToolsFixture({root}); + try { + await Promise.all([fixture.reset(),fixture.reset({mode:'stream'})]); + assert.equal(fixture.stats().closed.length,1); + const starting=fixture.reset(); + const closing=fixture.close(); + await assert.rejects(starting,/closed/); + await closing; + assert.equal(fixture.stats().current,undefined); + assert.equal(fixture.stats().closed.length,2); + for(const receipt of fixture.stats().closed) assert.ok(Object.values(receipt.closure).every(Boolean)); + } finally { await fixture.close(); } +}); +test('holding only a continuation never holds first actual canonical terminal', {timeout:30000}, async () => { + const server=await createClientToolsServer({root,environment,scenario:'cockpit',holdTerminal:true,holdAfterRequest:1}); + try { + const controller=new AbortController(); + const response=await fetch(server.url+'/agent',{method:'POST',body:JSON.stringify(input('Use get_weather')),signal:controller.signal}); + assert.equal(server.stats().requests[0].held,false); + controller.abort(); + } finally {await server.close();} +}); +test('one authored human drives two distinct canonical client-tool groups before final answer', {timeout:30000}, async () => { + const server=await createClientToolsServer({root,environment,scenario:'cockpit'}); + try { + await (await fetch(server.url+'/agent',{method:'POST',body:JSON.stringify(input('Rounds get_weather'))})).text(); + const first=server.stats().requests[0].events.filter(event=>event.type==='MESSAGES_SNAPSHOT').at(-1).messages; + const call=first.find(message=>message.toolCalls?.length).toolCalls[0]; + const continuation=input('', 'run-2',[...first,{id:call.id,role:'tool',toolCallId:call.id,content:JSON.stringify({location:'San Francisco',temperatureF:68,conditions:'Sunny',humidity:55,windMph:8})}]); + await (await fetch(server.url+'/agent',{method:'POST',body:JSON.stringify(continuation)})).text(); + const messages=server.stats().requests[1].events.filter(event=>event.type==='MESSAGES_SNAPSHOT').at(-1).messages; + assert.equal(messages.at(-1).toolCalls?.[0]?.function.name,'weather_card'); + assert.notEqual(messages.at(-1).toolCalls[0].id,call.id); + } finally {await server.close();} +}); +test('actual cockpit astream emits two partial canonical argument deltas after separate call header', {timeout:30000}, async () => { + const server=await createClientToolsServer({root,environment,scenario:'cockpit',mode:'stream'}); + try { + await (await fetch(server.url+'/agent',{method:'POST',body:JSON.stringify(input('Use weather_card'))})).text(); + const deltas=server.stats().requests[0].events.filter(event=>event.type==='TOOL_CALL_ARGS').map(event=>event.delta); + assert.equal(deltas.length,2); + assert.equal(JSON.parse(deltas.join('')).temperatureF,68); + assert.throws(()=>JSON.parse(deltas[0])); + } finally {await server.close();} +}); +test('receipt boundary holds actual final snapshot and terminal together without fabricating events', {timeout:30000}, async () => { + const server=await createClientToolsServer({root,environment,scenario:'cockpit',holdTerminal:true,holdReceipt:true}); + try { + const controller=new AbortController(); + const response=await fetch(server.url+'/agent',{method:'POST',body:JSON.stringify(input('Use get_weather')),signal:controller.signal}); + const chunk=await response.body.getReader().read(); + const text=new TextDecoder().decode(chunk.value); + assert.equal(text.includes('"type":"MESSAGES_SNAPSHOT"'),false); + controller.abort(); + } finally {await server.close();} +}); diff --git a/scripts/react-cockpit/ag-ui-client-tools-preparation.spec.mjs b/scripts/react-cockpit/ag-ui-client-tools-preparation.spec.mjs new file mode 100644 index 000000000..53dbb24b7 --- /dev/null +++ b/scripts/react-cockpit/ag-ui-client-tools-preparation.spec.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { root, project, environment } from './prepare-ag-ui-client-tools-fixture.mjs'; +const read = path => readFileSync(resolve(root,path),'utf8'); +test('native cockpit frozen prerequisite is uncached, absolute and independent of Library temp environment', () => { + assert.equal(project,resolve(root,'cockpit/ag-ui/client-tools/python')); + assert.equal(environment,resolve(project,'.venv')); + const config=JSON.parse(read('cockpit/ag-ui/client-tools/react/project.json')); + assert.equal(config.targets['prepare-fixture'].cache,false); + assert.deepEqual(config.targets.e2e.dependsOn,['prepare-fixture','build']); + assert.equal(config.targets.e2e.executor,'@nx/playwright:playwright'); + const source=read('scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs'); + assert.match(source,/\['sync', '--frozen', '--no-install-project', '--project', project\]/); + assert.match(source,/UV_PYTHON: '3.12', UV_PROJECT_ENVIRONMENT: environment/); + assert.doesNotMatch(source,/THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT|\/tmp\//); + assert.match(read('fixtures/react-parity/native-ag-ui-client-tools/server.mjs'),/process.env.THREADPLANE_AG_UI_FIXTURE_ENVIRONMENT \?\?/); + assert.match(read('scripts/react-cockpit/ag-ui-client-tools-fixture.mjs'),/createClientToolsServer\(\{ root, environment, scenario: 'cockpit'/); + assert.match(execFileSync('git',['check-ignore','cockpit/ag-ui/client-tools/python/.venv/pyvenv.cfg'],{cwd:root,encoding:'utf8'}),/\.venv/); +}); +test('website browser job prepares its own canonical worker after existing pinned Python setup', () => { + const workflow=read('.github/workflows/ci.yml'); + const website=workflow.split('\n website-e2e:\n')[1].split('\n website-preview-e2e:')[0]; + assert.equal((website.match(/astral-sh\/setup-uv@/g)??[]).length,1); + assert.match(website,/Prepare frozen dashboard browser producer[\s\S]*python-version: '3.12'/); + assert.match(website,/Prepare frozen canonical client tools browser producer\n run: node scripts\/react-cockpit\/prepare-ag-ui-client-tools-fixture.mjs/); + assert.ok(website.indexOf('prepare-ag-ui-client-tools-fixture.mjs') { + const serve=read('scripts/react-cockpit/serve.mjs'); + assert.match(serve,/configuration.adapter === 'ag-ui' && configuration.topic === 'client-tools' \? createAgUiClientToolsFixture/); + assert.match(serve,/configuration.adapter !== 'ag-ui' && configuration.topic === 'client-tools' \? createClientToolsFixture/); + assert.match(serve,/await \(agUiClientToolsFixture \?\?/); + assert.match(read('cockpit/ag-ui/client-tools/react/e2e/playwright.config.ts'),/prepare-ag-ui-client-tools-fixture.mjs --serve/); + assert.match(read('scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs'),/'ag-ui-client-tools', '--no-parent'/); +}); diff --git a/scripts/react-cockpit/configuration.mjs b/scripts/react-cockpit/configuration.mjs index b14831ecd..6fdda2147 100644 --- a/scripts/react-cockpit/configuration.mjs +++ b/scripts/react-cockpit/configuration.mjs @@ -128,6 +128,13 @@ export function reactCockpitConfiguration(topic = 'streaming') { port: 4612, project: 'cockpit-ag-ui-json-render-react', }); + if (topic === 'ag-ui-client-tools') + return Object.freeze({ + topic: 'client-tools', adapter: 'ag-ui', + appPath: 'cockpit/ag-ui/client-tools/react', + base: '/ag-ui/client-tools/react/', port: 4640, + project: 'cockpit-ag-ui-client-tools-react', + }); if (topic === 'ag-ui-tool-views') return Object.freeze({ topic: 'tool-views', diff --git a/scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs b/scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs new file mode 100644 index 000000000..c4280ca9a --- /dev/null +++ b/scripts/react-cockpit/prepare-ag-ui-client-tools-fixture.mjs @@ -0,0 +1,23 @@ +/** Shared local/CI prerequisite; no inherited Library fixture environment. */ +import { execFileSync, spawn } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +export const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +export const project = resolve(root, 'cockpit/ag-ui/client-tools/python'); +export const environment = resolve(project, '.venv'); +export function prepareClientToolsFixture() { + execFileSync('uv', ['sync', '--frozen', '--no-install-project', '--project', project], { + cwd: root, stdio: 'inherit', timeout: 180000, + env: {...process.env, UV_PYTHON: '3.12', UV_PROJECT_ENVIRONMENT: environment}, + }); +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + prepareClientToolsFixture(); + if (process.argv.includes('--serve')) { + const child = spawn(process.execPath, [resolve(root, 'scripts/react-cockpit/serve.mjs'), 'ag-ui-client-tools', '--no-parent'], {cwd: root, stdio:'inherit'}); + process.once('SIGTERM', () => child.kill('SIGTERM')); + process.once('SIGINT', () => child.kill('SIGINT')); + child.once('exit', code => process.exit(code ?? 1)); + child.once('error', () => process.exit(1)); + } +} diff --git a/scripts/react-cockpit/serve.mjs b/scripts/react-cockpit/serve.mjs index 9def2f962..4ac25abbc 100644 --- a/scripts/react-cockpit/serve.mjs +++ b/scripts/react-cockpit/serve.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { reactCockpitConfiguration } from './configuration.mjs'; import { createInterruptsFixture } from './interrupts-fixture.mjs'; import { createMemoryFixture } from './memory-fixture.mjs'; +import { createAgUiClientToolsFixture } from './ag-ui-client-tools-fixture.mjs'; import { createClientToolsFixture } from './client-tools-fixture.mjs'; import { createPersistenceFixture } from './persistence-fixture.mjs'; import { createDurableFixture } from './durable-fixture.mjs'; @@ -84,8 +85,9 @@ const interruptsFixture = : null; const memoryFixture = configuration.library !== 'deep-agents' && configuration.topic === 'memory' ? createMemoryFixture() : null; +const agUiClientToolsFixture = configuration.adapter === 'ag-ui' && configuration.topic === 'client-tools' ? createAgUiClientToolsFixture() : null; const clientToolsFixture = - configuration.topic === 'client-tools' ? createClientToolsFixture() : null; + configuration.adapter !== 'ag-ui' && configuration.topic === 'client-tools' ? createClientToolsFixture() : null; const root = resolve( dirname(fileURLToPath(import.meta.url)), @@ -115,6 +117,7 @@ const release = () => { }; const server = createServer(async (request, response) => { const pathname = new URL(request.url, 'http://localhost').pathname; + if (agUiClientToolsFixture && (await agUiClientToolsFixture(request, response, pathname))) return; if (deepAgentsMemoryFixture && (await deepAgentsMemoryFixture(request, response, pathname))) return; if (deepAgentsSkillsFixture && (await deepAgentsSkillsFixture(request, response, pathname))) return; if (deepAgentsSubagentsFixture && (await deepAgentsSubagentsFixture(request, response, pathname))) return; @@ -350,11 +353,11 @@ const server = createServer(async (request, response) => { server.listen(configuration.port, '127.0.0.1'); const parentServer = !process.argv.includes('--no-parent') ? createServer(server.listeners('request')[0]).listen(3000, '127.0.0.1') : null; -if (deepAgentsMemoryFixture || deepAgentsSubagentsFixture || deepAgentsSkillsFixture || deepAgentsFilesystemFixture || deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { +if (agUiClientToolsFixture || deepAgentsMemoryFixture || deepAgentsSubagentsFixture || deepAgentsSkillsFixture || deepAgentsFilesystemFixture || deepAgentsPlanningFixture || chatGenerativeUiFixture || chatInterruptsFixture || chatToolCallsFixture || chatSubagentsFixture || chatThreadsFixture || chatTimelineFixture) { let closing; const shutdown = (code = 0) => { closing ??= (async () => { - await (deepAgentsMemoryFixture ?? deepAgentsSubagentsFixture ?? deepAgentsSkillsFixture ?? deepAgentsFilesystemFixture ?? deepAgentsPlanningFixture ?? chatGenerativeUiFixture ?? chatInterruptsFixture ?? chatToolCallsFixture ?? chatSubagentsFixture ?? chatThreadsFixture ?? chatTimelineFixture).close(); + await (agUiClientToolsFixture ?? deepAgentsMemoryFixture ?? deepAgentsSubagentsFixture ?? deepAgentsSkillsFixture ?? deepAgentsFilesystemFixture ?? deepAgentsPlanningFixture ?? chatGenerativeUiFixture ?? chatInterruptsFixture ?? chatToolCallsFixture ?? chatSubagentsFixture ?? chatThreadsFixture ?? chatTimelineFixture).close(); await Promise.all([server, parentServer].filter(Boolean).map((owned) => new Promise((resolve) => { owned.close(resolve); owned.closeAllConnections(); diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index 485851daf..1678f9a55 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,8 +1,13 @@ { "schemaVersion": 1, - "baselineHead": "d618a6fc26c4d36129b3f25ef2ea63dec10cb886", + "baselineHead": "b69f180df57b997adea85e9d2c2e29736fd152d2", "sourceState": { - "modified": [], + "modified": [ + ".github/workflows/ci.yml", + "libs/cockpit-registry/src/lib/capability-registry.ts", + "libs/cockpit-registry/src/lib/content-descriptors.ts", + "libs/workspace-react/src/lib/components/code-mode/code-mode.tsx" + ], "untracked": [] }, "scope": { @@ -2148,7 +2153,7 @@ "id": "config:.github/workflows/ci.yml", "kind": "config", "path": ".github/workflows/ci.yml", - "sha256": "545dbf75ff9bcb3a2c8d4035da22c377aa13c20fb025345573846baef2c19233" + "sha256": "63e3504d5925b09d968ad56a9271814d374de910600a757b04c7e20c216b5bcf" }, { "id": "config:.github/workflows/publish-middleware-npm.yml", @@ -13473,13 +13478,13 @@ "id": "source:libs/cockpit-registry/src/lib/capability-registry.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/capability-registry.ts", - "sha256": "60edfd2b2f9ae703f395fd43e466eed663fe6898d725ae4282537e0ee1bc3884" + "sha256": "71fb516c9e668e577972d0534355cd491b748bb7ab3c70439dda90493072c91b" }, { "id": "source:libs/cockpit-registry/src/lib/content-descriptors.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/content-descriptors.ts", - "sha256": "446be6436f7b8d5b614fa36b7fd0c5b3a8248005ef756d0b292d7902424b4911" + "sha256": "3081771e78e7cd6b196d9ab5f8725009809d179793f9fcef613e81d0c8ae9ecf" }, { "id": "source:libs/cockpit-registry/src/lib/docs-links.ts", @@ -15045,7 +15050,7 @@ "id": "source:libs/workspace-react/src/lib/components/code-mode/code-mode.tsx", "kind": "source", "path": "libs/workspace-react/src/lib/components/code-mode/code-mode.tsx", - "sha256": "73dd092ea1ac482cd3947def92f7987cbcc79bef9a2ff37bf77fec56a870dec5" + "sha256": "bc31ed9589d3f26a9758a7ad6425077c22153a5c20b237a3600094e58cdb9a1c" }, { "id": "source:libs/workspace-react/src/lib/components/code-mode/file-tree.tsx", diff --git a/scripts/react-parity/dispositions.json b/scripts/react-parity/dispositions.json index 3b74faac8..d7c7899f3 100644 --- a/scripts/react-parity/dispositions.json +++ b/scripts/react-parity/dispositions.json @@ -10831,8 +10831,9 @@ "T34" ], "treatment": "internal", - "status": "planned", - "reason": "Private cockpit, example, or test infrastructure; adapt for React scenarios without adding a public package API." + "status": "in-progress", + "reason": "Private cockpit, example, or test infrastructure; adapt for React scenarios without adding a public package API.", + "note": "Bounded AG-UI Client Tools private React source-build preview registered with canonical Angular default and shared backend; broader parity and public release remain open." }, { "id": "source:libs/cockpit-registry/src/lib/content-descriptors.ts", @@ -10841,8 +10842,9 @@ "T34" ], "treatment": "internal", - "status": "planned", - "reason": "Private cockpit, example, or test infrastructure; adapt for React scenarios without adding a public package API." + "status": "in-progress", + "reason": "Private cockpit, example, or test infrastructure; adapt for React scenarios without adding a public package API.", + "note": "Bounded AG-UI Client Tools private React source-build preview registered with canonical Angular default and shared backend; broader parity and public release remain open." }, { "id": "source:libs/cockpit-registry/src/lib/docs-links.ts", @@ -12890,8 +12892,9 @@ "T34" ], "treatment": "internal", - "status": "planned", - "reason": "Private cockpit, example, or test infrastructure; adapt for React scenarios without adding a public package API." + "status": "in-progress", + "reason": "Private cockpit, example, or test infrastructure; adapt for React scenarios without adding a public package API.", + "note": "Stable path deduplication admits canonical prompt includes without duplicate Code tree rows or tabs; broader workspace migration remains open." }, { "id": "source:libs/workspace-react/src/lib/components/code-mode/file-tree.tsx",