diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index d9055ce..eef5017 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -30,10 +30,8 @@ jobs: - run: npm install -g npm@^11.5.1 - run: npm --version - run: npm ci - - run: npm test - - run: node --test tests/*.test.mjs - - run: npm run build:all - - run: npm pack --dry-run --access public + - run: npx playwright install --with-deps chromium + - run: npm run release:preflight - name: Check published version id: published diff --git a/CHANGELOG.md b/CHANGELOG.md index a715c5c..8520627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.3.3 - 2026-08-28 + +### Headless conversation surface + +- Add the Node/SSR-safe `@kingsoftcloud/ksadk-web/conversation` entrypoint with + strict ConversationSurface/Input/Item v1 decoders, input preflight, bounded + HTTP/SSE reconnect, passive renderer data, and the shared identity reducer. +- Route the bundled Hosted UI through the same conversation client and reducer + when a valid Surface is advertised. A Surface HTTP 404 keeps the existing + Responses / AG-UI / legacy path; malformed surfaces and server failures do + not silently bypass the declared contract. +- Preserve different item identities even when their text is equal, ignore + replayed `(itemId, sourceEventId)` pairs, keep terminal items monotonic, and + retain additive unknown item kinds for replay/audit without rendering a + repeated transcript card; newer schemas on known kinds safely downgrade to + one passive fallback card. +- Preserve the canonical item timeline for renderers: a separate native tool + result enriches its original `callId` tool card rather than rendering a + duplicate card, while reasoning, tools and answers keep their original + interleaving. Add an immutable exact `kind + payloadSchemaRef` trusted + renderer catalog; providers cannot supply executable UI code in event + payloads or claim a future schema version. +- Keep `output` and `reasoning` summaries as a compatibility view beside the + canonical timeline, so Studio can adopt the shared reducer without a second + text aggregation implementation during the 0.8.3 transition. +- Keep canonical approvals without a durable `revision` read-only. Consumers + must not guess a revision or submit them through the revision-CAS Interaction + API until the server supplies an authoritative value. +- Make attachment upload and model selection first-class canonical inputs in + Hosted UI. Unsupported inputs and oversized files fail before upload or turn + submission instead of silently degrading to legacy `RunAgent` behavior. +- Prove the headless entrypoint from a minimal independent consumer across two + turns, cursor reconnect, text/tool/approval/unknown-item rendering, and + revision-CAS approval submission. +- Add a repeatable release preflight that runs unit, Node contract, lint, all + production builds, canonical Conversation browser E2E, provenance checks, + npm packing, and a clean tarball-install public API smoke test. + ## 0.3.2 - 2026-08-21 Release candidate for the durable Interaction/v1 web experience. This is the diff --git a/README.md b/README.md index 57f22ca..d721bb8 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ package entrypoints under `dist-lib`. The npm package exposes these stable entrypoints: - `@kingsoftcloud/ksadk-web/components` +- `@kingsoftcloud/ksadk-web/conversation` (headless, Node/SSR-safe) - `@kingsoftcloud/ksadk-web/runtime` - `@kingsoftcloud/ksadk-web/capabilities` - `@kingsoftcloud/ksadk-web/styles` @@ -51,6 +52,57 @@ The npm package exposes these stable entrypoints: Hosted UI should import the shared shell from the package and keep private auth, routing, feature flags, Docker, nginx, and Helm logic in its own repo. +### Headless conversation client + +`@kingsoftcloud/ksadk-web/conversation` provides strict +`ConversationSurface/Input/Item` decoders, the identity reducer, passive +renderer data, and a small HTTP/SSE reference client. It has no React or DOM +runtime dependency and can be imported by Node/SSR applications. + +```ts +import { + HttpConversationClient, + buildConversationInput, +} from '@kingsoftcloud/ksadk-web/conversation' + +const client = new HttpConversationClient() +const bootstrap = await client.getSurface('agent-id', 'session-id') +const result = await client.streamTurn({ + bootstrap, + input: buildConversationInput({ + inputId: 'input-id', + sessionId: 'session-id', + idempotencyKey: 'turn-id', + parts: [{ kind: 'text', text: 'Hello' }], + }), + onUpdate(snapshot) { + // Render snapshot.presentation. It is already reduced by canonical item + // identity and is safe to replace after a reconnect or replay. + renderConversation(snapshot.presentation) + }, +}) +``` + +The client submits a turn once and only reconnects through the canonical Run +event endpoint. `onUpdate` and the final result use the same reducer: equal text +from different item identities is retained, replayed `(itemId, sourceEventId)` +pairs are idempotent, and a terminal item never regresses. It does not accept +tokens, cookies, credential modes, or provider-specific request fields; +applications keep authentication at their same-origin server boundary or in +an injected transport. + +The bundled Hosted UI uses this same client and reducer when the server returns +a valid `ConversationSurface`. HTTP 404 is the compatibility signal for the +existing Responses / AG-UI / legacy path. A declared but invalid or unavailable +surface fails closed, and unknown item kinds or schema versions render as +passive fallback cards rather than provider-specific UI. + +Approval and structured-input items are actionable only when their canonical +payload carries the server's durable, non-negative `revision`. Without that +value the shared Hosted UI intentionally renders the item read-only: it never +guesses revision `0` or bypasses the Interaction API's revision-CAS contract. +Likewise, unknown payloads and unsafe artifact URIs remain passive content. + ## Release Contract Consumers should record the resolved KSADK Web package version and lockfile @@ -89,12 +141,33 @@ Before creating a release or dispatching the workflow, verify the payload: ```bash npm ci -npm test -node --test tests/*.test.mjs -npm run build:all -npm pack --dry-run --access public +npx playwright install chromium +npm run release:preflight ``` +The preflight is intentionally stricter than a development build: it requires +a clean worktree, checks the frozen Git source recorded in +`RELEASE_PROVENANCE.json`, rejects content changes under an already tagged +version, runs the canonical Conversation browser flow, creates the real npm +tarball, installs it into a disposable consumer, and imports the public +`@kingsoftcloud/ksadk-web/conversation` API. During development only, use +`npm run release:preflight -- --allow-unreleased --allow-dirty` to rehearse the +same tests without claiming the current commit is a releasable source. + +After the next version is set and all code is committed, freeze its provenance +from that clean commit before the final attestation commit: + +```bash +npm run release:provenance -- generate +git add RELEASE_PROVENANCE.json +git commit -m "chore(release): attest ksadk-web source" +npm run release:preflight +``` + +The generator refuses dirty worktrees and versions whose `vX.Y.Z` tag already +exists. Never edit `source_commit` by hand or regenerate provenance for an +already published version. + The publish workflow checks whether `package.json`'s exact version is already present on npm. Existing versions are skipped because npm packages are immutable; publish a new patch version for any package-content change. diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index f87558f..bdcdf05 100644 --- a/RELEASE_PROVENANCE.json +++ b/RELEASE_PROVENANCE.json @@ -1,7 +1,7 @@ { "schema_version": 1, "package": "@kingsoftcloud/ksadk-web", - "version": "0.3.2", - "source_commit": "2136448e038b4d8c475fa20e4722252b1ddb2ebc", + "version": "0.3.3", + "source_commit": "37eb8dd6ae8c44ea4622d39f8c7ae9b13916a793", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" } diff --git a/e2e/agui.spec.mjs b/e2e/agui.spec.mjs index 62004de..70b6347 100644 --- a/e2e/agui.spec.mjs +++ b/e2e/agui.spec.mjs @@ -76,6 +76,17 @@ function bootstrap() { async function installFixture(page) { const state = { approved: false, created: false, aguiBodies: [] }; + // AG-UI is the intended transport in this fixture. Explicitly advertise + // canonical ConversationSurface absence so production fallback semantics + // are exercised instead of receiving Vite's index.html with HTTP 200. + await page.route('**/api/v1/agents/**/conversation-surface**', async (route) => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: 'conversation surface unavailable in AG-UI fixture' }), + }); + }); + await page.route('**/agentengine/agui', async (route) => { const body = route.request().postDataJSON(); state.aguiBodies.push(body); diff --git a/e2e/canonical-conversation.spec.ts b/e2e/canonical-conversation.spec.ts new file mode 100644 index 0000000..0bf2ef9 --- /dev/null +++ b/e2e/canonical-conversation.spec.ts @@ -0,0 +1,337 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; + +const FIXTURE_ORIGIN = 'http://127.0.0.1:4182'; +const APPROVAL_ID = 'approval-shell-1'; +const APPROVAL_REVISION = 7; + +type FixtureState = { + config: { attachmentInputs: boolean }; + inputs: Array>; + uploads: Array<{ + filename: string; + mediaType: string; + agentId: string; + contentType: string; + bodyBytes: number; + attachmentRef: string; + }>; + streamPosts: number; + legacyRunAgentCalls: number; + reconnects: Array<{ after: number; lastEventId: string | null }>; + submits: Array>; + winner: { action: string; idempotencyKey: string; revision: number } | null; +}; + +async function fixtureState(request: APIRequestContext): Promise { + const response = await request.get(`${FIXTURE_ORIGIN}/__fixture/state`); + expect(response.ok()).toBe(true); + return response.json(); +} + +async function setFixtureConfig( + request: APIRequestContext, + config: Partial, +): Promise { + const response = await request.post(`${FIXTURE_ORIGIN}/__fixture/config`, { data: config }); + expect(response.ok()).toBe(true); +} + +async function openCanonicalHostedUi(page: Page): Promise { + await page.goto('/'); + await expect( + page.getByRole('main').getByText('Canonical Fixture', { exact: true }), + ).toBeVisible(); +} + +async function attachThroughComposer( + page: Page, + file: { name: string; mimeType: string; buffer: Buffer }, +): Promise { + await page.getByRole('button', { name: '添加附件或选择执行模式' }).click(); + const chooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('menuitem', { name: /上传附件/ }).click(); + const chooser = await chooserPromise; + await chooser.setFiles(file); + await expect(page.getByText(file.name, { exact: true })).toBeVisible(); +} + +test.beforeEach(async ({ request }) => { + const response = await request.post(`${FIXTURE_ORIGIN}/__fixture/reset`); + expect(response.ok()).toBe(true); +}); + +test('canonical Hosted UI survives replay and hides additive events safely', async ({ page, request }) => { + const a2uiReplayErrors: string[] = []; + page.on('console', (message) => { + if (message.text().includes('[A2UI] processMessages error')) { + a2uiReplayErrors.push(message.text()); + } + }); + await openCanonicalHostedUi(page); + + const composer = page.locator('textarea[placeholder^="发送消息"]'); + await composer.fill('执行第一轮 canonical 会话'); + await composer.press('Enter'); + + // The first HTTP stream ends before a terminal item. The browser must + // reconnect with both cursor forms and never duplicate the replay boundary. + await expect.poll(async () => (await fixtureState(request)).reconnects).toEqual([ + { after: 3, lastEventId: '3' }, + ]); + await expect(page.getByText('read_config', { exact: true })).toHaveCount(1); + + // Reasoning, tool and passive A2UI travel through the same canonical stream + // and real renderer. Additive unknown events remain in replay/audit but are + // deliberately not turned into transcript noise. + await expect(page.getByText('Canonical A2UI 卡片')).toBeVisible(); + await expect(page.getByText(/Unsupported content: This content type is not supported/)).toHaveCount(0); + await expect(page.getByText('', { exact: true })).toHaveCount(0); + + const tray = page.getByTestId('interaction-tray'); + await expect(tray).toBeVisible(); + await expect(page.getByTestId('interaction-tray-title')).toHaveText('执行安全检查'); + await expect(page.getByTestId('interaction-tray-message')).toHaveText('允许执行只读环境检查?'); + + // A double click is still one browser submit. The request carries the + // durable revision and deterministic idempotency key. + await page.getByTestId('interaction-approve').dblclick(); + await expect.poll(async () => (await fixtureState(request)).submits.length).toBe(1); + let current = await fixtureState(request); + expect(current.submits[0]).toMatchObject({ + InteractionId: APPROVAL_ID, + ExpectedRevision: APPROVAL_REVISION, + Action: 'approve', + IdempotencyKey: `interaction:${APPROVAL_ID}:revision-${APPROVAL_REVISION}`, + }); + + // Same idempotency key is a duplicate receipt; a competing decision with a + // different key loses. Neither can replace the first accepted decision. + const duplicate = await request.post( + `${FIXTURE_ORIGIN}/agentengine/api/v1/SubmitInteraction`, + { + data: { + InteractionId: APPROVAL_ID, + ExpectedRevision: APPROVAL_REVISION, + Action: 'approve', + IdempotencyKey: `interaction:${APPROVAL_ID}:revision-${APPROVAL_REVISION}`, + }, + }, + ); + expect((await duplicate.json()).Data.status).toBe('duplicate'); + const loser = await request.post( + `${FIXTURE_ORIGIN}/agentengine/api/v1/SubmitInteraction`, + { + data: { + InteractionId: APPROVAL_ID, + ExpectedRevision: APPROVAL_REVISION, + Action: 'reject', + IdempotencyKey: 'competing-decision', + }, + }, + ); + const loserReceipt = (await loser.json()).Data; + expect(loserReceipt.status).toBe('rejected'); + expect(loserReceipt.error.code).toBe('interaction_already_resolved'); + + await expect(page.getByText('第一轮完成。', { exact: true })).toBeVisible(); + await expect(page.getByText('第一轮完成。', { exact: true })).toHaveCount(1); + await expect(tray).toHaveCount(0); + await expect(page.getByTestId('interaction-history-anchor')).toHaveAttribute( + 'data-interaction-status', + 'resolved', + ); + + const thinking = page.getByRole('button', { name: /已思考/ }); + await expect(thinking).toBeVisible(); + await thinking.click(); + await expect(page.getByText('先检查环境。', { exact: true })).toBeVisible(); + + // A second user turn uses the same ConversationSurface path and leaves the + // first turn intact. No legacy RunAgent request is allowed as a hidden + // fallback once the canonical surface was admitted. + await composer.fill('继续第二轮'); + await composer.press('Enter'); + await expect(page.getByText('第二轮也正常。', { exact: true })).toBeVisible(); + await expect(page.getByText('第二轮也正常。', { exact: true })).toHaveCount(1); + await expect(page.getByText('第一轮完成。', { exact: true })).toHaveCount(1); + + current = await fixtureState(request); + expect(current.streamPosts).toBe(2); + expect(current.legacyRunAgentCalls).toBe(0); + expect(current.inputs).toHaveLength(2); + expect(current.inputs.map((input) => input.parts)).toEqual([ + [{ kind: 'text', text: '执行第一轮 canonical 会话' }], + [{ kind: 'text', text: '继续第二轮' }], + ]); + expect(current.winner).toEqual({ + action: 'approve', + idempotencyKey: `interaction:${APPROVAL_ID}:revision-${APPROVAL_REVISION}`, + revision: APPROVAL_REVISION, + }); + expect(a2uiReplayErrors).toEqual([]); +}); + +test('Hosted UI sends an allowed attachment and selected model only through canonical input', async ({ page, request }) => { + await openCanonicalHostedUi(page); + + const modelButton = page.getByRole('button', { name: /模型 Fixture Model/ }); + await expect(modelButton).toBeVisible(); + await modelButton.click(); + await page.getByRole('menuitemradio', { name: 'Fixture Model Alt' }).click(); + await expect(page.getByRole('button', { name: /模型 Fixture Model Alt/ })).toBeVisible(); + + await attachThroughComposer(page, { + name: 'canonical-notes.txt', + mimeType: 'text/plain', + buffer: Buffer.from('canonical attachment payload', 'utf8'), + }); + const composer = page.locator('textarea[placeholder^="发送消息"]'); + await composer.fill('带附件并切换模型'); + await composer.press('Enter'); + + await expect.poll(async () => (await fixtureState(request)).inputs.length).toBe(1); + const current = await fixtureState(request); + expect(current.uploads).toHaveLength(1); + expect(current.uploads[0]).toMatchObject({ + filename: 'canonical-notes.txt', + mediaType: 'text/plain', + agentId: 'canonical-fixture-agent', + attachmentRef: 'attachment://canonical/1/canonical-notes.txt', + }); + expect(current.uploads[0].contentType).toContain('multipart/form-data'); + expect(current.uploads[0].bodyBytes).toBeGreaterThan('canonical attachment payload'.length); + + const input = current.inputs[0]; + expect(input).toEqual({ + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationInput', + inputId: expect.stringMatching(/^input:run_/), + sessionId: 'canonical-fixture-session', + idempotencyKey: expect.stringMatching(/^conversation:run_/), + parts: [ + { kind: 'text', text: '带附件并切换模型' }, + { + kind: 'attachment', + attachmentRef: 'attachment://canonical/1/canonical-notes.txt', + mediaType: 'text/plain', + name: 'canonical-notes.txt', + }, + ], + modelRef: 'fixture-model-alt', + extensions: { + 'ksadk.approval': 'risk', + }, + }); + expect(input.idempotencyKey).toBe( + `conversation:${String(input.inputId).replace(/^input:/, '')}`, + ); + expect(current.streamPosts).toBe(1); + expect(current.legacyRunAgentCalls).toBe(0); +}); + +test('Hosted UI fails closed before upload when attachment capability is absent', async ({ page, request }) => { + await setFixtureConfig(request, { attachmentInputs: false }); + await openCanonicalHostedUi(page); + await attachThroughComposer(page, { + name: 'not-admitted.txt', + mimeType: 'text/plain', + buffer: Buffer.from('must not be uploaded', 'utf8'), + }); + const composer = page.locator('textarea[placeholder^="发送消息"]'); + await composer.fill('禁止附件必须 fail closed'); + await composer.press('Enter'); + + await expect(page.getByText('连接断开或生成出错,请重试', { exact: true })).toBeVisible(); + const current = await fixtureState(request); + expect(current.uploads).toEqual([]); + expect(current.inputs).toEqual([]); + expect(current.streamPosts).toBe(0); + expect(current.legacyRunAgentCalls).toBe(0); +}); + +test('Hosted UI rejects an oversized attachment before upload or canonical submit', async ({ page, request }) => { + await openCanonicalHostedUi(page); + await page.evaluate(() => { + const input = document.querySelector('input[type="file"]'); + if (!input) throw new Error('attachment input missing'); + // Reuse one immutable Blob as 101 parts. This proves the browser File is + // over 100 MiB without allocating 101 independent payload buffers. + const oneMiB = new Blob([new Uint8Array(1024 * 1024)]); + const file = new File(Array.from({ length: 101 }, () => oneMiB), 'oversized.bin', { + type: 'application/octet-stream', + }); + const transfer = new DataTransfer(); + transfer.items.add(file); + input.files = transfer.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + await expect(page.getByText('oversized.bin', { exact: true })).toBeVisible(); + + const composer = page.locator('textarea[placeholder^="发送消息"]'); + await composer.fill('超大附件必须 fail closed'); + await composer.press('Enter'); + await expect(page.getByText('连接断开或生成出错,请重试', { exact: true })).toBeVisible(); + + const current = await fixtureState(request); + expect(current.uploads).toEqual([]); + expect(current.inputs).toEqual([]); + expect(current.streamPosts).toBe(0); + expect(current.legacyRunAgentCalls).toBe(0); +}); + +test('independent custom frontend consumes the public conversation API across replay and two turns', async ({ page, request }) => { + await page.goto('/e2e/fixtures/custom-conversation-consumer.html'); + await expect(page.getByRole('heading', { name: 'Independent Conversation Consumer' })).toBeVisible(); + + const message = page.getByLabel('Message'); + await message.fill('独立前端第一轮'); + await page.getByRole('button', { name: 'Send' }).click(); + + await expect.poll(async () => (await fixtureState(request)).reconnects).toEqual([ + { after: 3, lastEventId: '3' }, + ]); + await expect(page.locator('[data-kind="tool"]')).toHaveText('read_config'); + await expect(page.locator('[data-kind="tool"]')).toHaveCount(1); + await expect(page.locator('[data-kind="approval"]')).toContainText('执行安全检查'); + await expect(page.locator('[data-kind="fallback"]')).toHaveCount(0); + + await page.getByRole('button', { name: 'Approve' }).click(); + await expect(page.getByRole('status')).toHaveText('completed-1'); + await expect(page.locator('[data-kind="assistant_text"]')).toContainText('第一轮完成。'); + + await message.fill('独立前端第二轮'); + await page.getByRole('button', { name: 'Send' }).click(); + await expect(page.getByRole('status')).toHaveText('completed-2'); + await expect(page.locator('[data-run-id="canonical-run-1"]')).toContainText('第一轮完成。'); + await expect(page.locator('[data-run-id="canonical-run-2"]')).toContainText('第二轮也正常。'); + + const current = await fixtureState(request); + expect(current.inputs).toEqual([ + { + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationInput', + inputId: 'custom-input-1', + sessionId: 'canonical-fixture-session', + idempotencyKey: 'custom-turn-1', + parts: [{ kind: 'text', text: '独立前端第一轮' }], + modelRef: 'fixture-model', + }, + { + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationInput', + inputId: 'custom-input-2', + sessionId: 'canonical-fixture-session', + idempotencyKey: 'custom-turn-2', + parts: [{ kind: 'text', text: '独立前端第二轮' }], + modelRef: 'fixture-model', + }, + ]); + expect(current.streamPosts).toBe(2); + expect(current.legacyRunAgentCalls).toBe(0); + expect(current.submits).toEqual([{ + InteractionId: APPROVAL_ID, + ExpectedRevision: APPROVAL_REVISION, + Action: 'approve', + IdempotencyKey: `interaction:${APPROVAL_ID}:revision-${APPROVAL_REVISION}`, + }]); +}); diff --git a/e2e/fixtures/canonical-conversation-server.mjs b/e2e/fixtures/canonical-conversation-server.mjs new file mode 100644 index 0000000..c262c48 --- /dev/null +++ b/e2e/fixtures/canonical-conversation-server.mjs @@ -0,0 +1,568 @@ +import { createServer } from 'node:http'; + +const HOST = '127.0.0.1'; +const PORT = 4182; +const AGENT_ID = 'canonical-fixture-agent'; +const SESSION_ID = 'canonical-fixture-session'; +const BUILD_ID = 'canonical-fixture-build'; +const APPROVAL_ID = 'approval-shell-1'; +const APPROVAL_REVISION = 7; +const CATALOG_ID = 'https://a2ui.org/specification/v0_9/basic_catalog.json'; + +const delay = (milliseconds) => new Promise((resolve) => { + setTimeout(resolve, milliseconds); +}); + +function createState() { + return { + config: { + attachmentInputs: true, + }, + inputs: [], + uploads: [], + streamPosts: 0, + legacyRunAgentCalls: 0, + reconnects: [], + submits: [], + winner: null, + continueFirstRun: null, + }; +} + +let state = createState(); + +function envelope(data) { + return { Code: 0, Message: 'Success', Data: data }; +} + +function sendJson(response, value, status = 200) { + response.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); + response.end(JSON.stringify(value)); +} + +async function readBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return Buffer.concat(chunks); +} + +function receipt(status, commandId, error = null) { + return envelope({ + schema_version: 1, + command_id: commandId, + status, + message_id: null, + run_id: status === 'accepted' || status === 'duplicate' ? 'canonical-run-1' : null, + accepted_seq: status === 'accepted' ? 8 : null, + error, + }); +} + +async function readJson(request) { + const body = await readBody(request); + if (body.length === 0) return {}; + return JSON.parse(body.toString('utf8')); +} + +async function handleUpload(request, response) { + const contentType = request.headers['content-type'] || ''; + const body = await readBody(request); + const text = body.toString('latin1'); + const filename = /name="file"; filename="([^"]+)"/i.exec(text)?.[1] || 'attachment.bin'; + const mediaType = /name="file"; filename="[^"]+"\r?\nContent-Type: ([^\r\n]+)/i.exec(text)?.[1] + || 'application/octet-stream'; + const agentId = /name="AgentId"\r?\n\r?\n([^\r\n]+)/i.exec(text)?.[1] || ''; + const uploadNumber = state.uploads.length + 1; + const attachmentRef = `attachment://canonical/${uploadNumber}/${encodeURIComponent(filename)}`; + state.uploads.push({ + filename, + mediaType, + agentId, + contentType, + bodyBytes: body.length, + attachmentRef, + }); + sendJson(response, envelope({ + FileData: { + fileUri: attachmentRef, + displayName: filename, + mimeType: mediaType, + }, + })); +} + +function bootstrap() { + return { + Agent: { AgentId: AGENT_ID, Name: 'Canonical Fixture', Framework: 'codex' }, + ApiFormats: ['responses'], + Capabilities: { + Attachments: true, + WorkspaceFiles: false, + Approval: true, + Thinking: true, + StopRun: true, + ResumeRun: false, + interaction_v1: { enabled: true }, + RunLifecycle: { Enabled: false }, + }, + HostedChat: { + PreferredTransport: 'responses', + Transports: [{ + Protocol: 'responses', + Runtime: 'codex', + Endpoint: '/v1/responses', + Version: 'v1', + Capabilities: { A2UI: true, Interrupt: true, Cancel: true }, + }], + }, + Model: { id: 'fixture-model', display_name: 'Fixture Model' }, + }; +} + +function surface() { + return { + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationSurface', + surfaceId: 'canonical-hosted-ui', + sessionId: SESSION_ID, + providerRef: 'agent.provider/v1:fixture-codex', + inputs: [ + { name: 'text', mode: 'native' }, + { name: 'model.select', mode: 'native' }, + { name: 'approval', mode: 'native' }, + ...(state.config.attachmentInputs ? [ + { name: 'attachment.file', mode: 'native' }, + { name: 'attachment.image', mode: 'native' }, + ] : []), + ], + outputs: [ + { name: 'text', mode: 'native' }, + { name: 'reasoning', mode: 'native' }, + { name: 'tool.read_config', mode: 'native' }, + { name: 'approval', mode: 'native' }, + { name: 'a2ui', mode: 'native' }, + ], + }; +} + +function item({ + runId, + itemId, + sourceEventId, + kind, + schema, + payload = {}, + operation = 'append', + lifecycle = 'streaming', +}) { + return { + apiVersion: 'conversation.ksadk.io/v1', + kindVersion: 1, + itemId, + sourceEventIds: [sourceEventId], + sessionId: SESSION_ID, + runId, + kind, + operation, + lifecycle, + visibility: 'public', + payloadSchemaRef: schema, + payload, + nativeRef: { fixture: true }, + }; +} + +function writeFrame(response, cursor, conversationItem) { + response.write(`id: ${cursor}\ndata: ${JSON.stringify({ conversationItem })}\n\n`); +} + +function beginSse(response) { + response.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + response.flushHeaders(); +} + +function a2uiOperations() { + return [ + { + version: 'v0.9', + createSurface: { surfaceId: 'canonical-status', catalogId: CATALOG_ID }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'canonical-status', + components: [ + { id: 'root', component: 'Column', children: ['canonical-status-title'] }, + { + id: 'canonical-status-title', + component: 'Text', + variant: 'h3', + text: 'Canonical A2UI 卡片', + }, + ], + }, + }, + ]; +} + +function firstToolStreaming() { + return item({ + runId: 'canonical-run-1', + itemId: 'tool-config', + sourceEventId: 'event-tool-start', + kind: 'tool_call', + schema: 'conversation.item.tool-call/v1', + payload: { callId: 'call-config', tool: 'read_config', args: { path: 'agent.yaml' } }, + }); +} + +async function streamFirstTurn(response) { + beginSse(response); + writeFrame(response, 1, item({ + runId: 'canonical-run-1', + itemId: 'reasoning-main', + sourceEventId: 'event-reasoning-1', + kind: 'reasoning', + schema: 'conversation.item.reasoning/v1', + payload: { text: '先检查' }, + })); + await delay(35); + writeFrame(response, 2, item({ + runId: 'canonical-run-1', + itemId: 'reasoning-main', + sourceEventId: 'event-reasoning-2', + kind: 'reasoning', + schema: 'conversation.item.reasoning/v1', + payload: { text: '环境。' }, + })); + await delay(35); + writeFrame(response, 3, firstToolStreaming()); + await delay(35); + // A clean early EOF is the deterministic disconnect. The client has a run + // identity and cursor, so it must continue through the replay endpoint. + response.end(); +} + +async function streamFirstReconnect(response, requestUrl, request) { + const after = Number(requestUrl.searchParams.get('after') || '0'); + state.reconnects.push({ + after, + lastEventId: request.headers['last-event-id'] || null, + }); + beginSse(response); + // Deliberately replay the boundary source. Identity reduction must prevent + // a duplicate tool card even if the transport repeats the last event. + writeFrame(response, 3, firstToolStreaming()); + writeFrame(response, 4, item({ + runId: 'canonical-run-1', + itemId: 'tool-config', + sourceEventId: 'event-tool-completed', + kind: 'tool_call', + schema: 'conversation.item.tool-call/v1', + payload: { + callId: 'call-config', + tool: 'read_config', + args: { path: 'agent.yaml' }, + output: { ok: true, model: 'fixture-model' }, + }, + operation: 'completed', + lifecycle: 'completed', + })); + writeFrame(response, 5, item({ + runId: 'canonical-run-1', + itemId: 'approval-shell', + sourceEventId: 'event-approval-requested', + kind: 'approval', + schema: 'conversation.item.approval/v1', + payload: { + interactionId: APPROVAL_ID, + revision: APPROVAL_REVISION, + kind: 'shell', + title: '执行安全检查', + prompt: '允许执行只读环境检查?', + detail: { command: 'env --version' }, + createdAt: '2026-08-28T00:00:00Z', + }, + lifecycle: 'pending', + })); + writeFrame(response, 6, item({ + runId: 'canonical-run-1', + itemId: 'a2ui-status', + sourceEventId: 'event-a2ui', + kind: 'a2ui', + schema: 'conversation.item.a2ui/v1', + payload: { data: a2uiOperations() }, + operation: 'completed', + lifecycle: 'completed', + })); + writeFrame(response, 7, item({ + runId: 'canonical-run-1', + itemId: 'future-game-card', + sourceEventId: 'event-future-kind', + kind: 'vendor_game_card', + schema: 'vendor.game-card/v9', + payload: { html: '' }, + operation: 'completed', + lifecycle: 'completed', + })); + + await new Promise((resolve) => { + const finish = () => { + writeFrame(response, 8, item({ + runId: 'canonical-run-1', + itemId: 'approval-shell', + sourceEventId: 'event-approval-resolved', + kind: 'approval', + schema: 'conversation.item.approval/v1', + payload: { + interactionId: APPROVAL_ID, + revision: APPROVAL_REVISION, + kind: 'shell', + title: '执行安全检查', + prompt: '允许执行只读环境检查?', + detail: { command: 'env --version' }, + outcome: 'approved', + actor: 'fixture-user', + resolvedAt: '2026-08-28T00:00:01Z', + }, + operation: 'completed', + lifecycle: 'completed', + })); + writeFrame(response, 9, item({ + runId: 'canonical-run-1', + itemId: 'assistant-answer-1', + sourceEventId: 'event-answer-1a', + kind: 'assistant_text', + schema: 'conversation.item.assistant_text/v1', + payload: { text: '第一轮' }, + })); + writeFrame(response, 10, item({ + runId: 'canonical-run-1', + itemId: 'assistant-answer-1', + sourceEventId: 'event-answer-1b', + kind: 'assistant_text', + schema: 'conversation.item.assistant_text/v1', + payload: { text: '完成。' }, + lifecycle: 'completed', + })); + writeFrame(response, 11, item({ + runId: 'canonical-run-1', + itemId: 'run-terminal-1', + sourceEventId: 'event-terminal-1', + kind: 'progress', + schema: 'conversation.item.progress/v1', + operation: 'completed', + lifecycle: 'completed', + })); + response.end(); + state.continueFirstRun = null; + resolve(); + }; + state.continueFirstRun = finish; + response.once('close', () => { + if (!response.writableEnded) { + state.continueFirstRun = null; + resolve(); + } + }); + }); +} + +async function streamSecondTurn(response) { + beginSse(response); + writeFrame(response, 1, item({ + runId: 'canonical-run-2', + itemId: 'assistant-answer-2', + sourceEventId: 'event-answer-2a', + kind: 'assistant_text', + schema: 'conversation.item.assistant_text/v1', + payload: { text: '第二轮' }, + })); + await delay(45); + writeFrame(response, 2, item({ + runId: 'canonical-run-2', + itemId: 'assistant-answer-2', + sourceEventId: 'event-answer-2b', + kind: 'assistant_text', + schema: 'conversation.item.assistant_text/v1', + payload: { text: '也正常。' }, + lifecycle: 'completed', + })); + await delay(25); + writeFrame(response, 3, item({ + runId: 'canonical-run-2', + itemId: 'run-terminal-2', + sourceEventId: 'event-terminal-2', + kind: 'progress', + schema: 'conversation.item.progress/v1', + operation: 'completed', + lifecycle: 'completed', + })); + response.end(); +} + +async function handleAgentApi(request, response, requestUrl) { + const action = requestUrl.pathname.split('/').pop(); + if (action === 'UploadFile' && request.method === 'POST') { + await handleUpload(request, response); + return; + } + const body = request.method === 'POST' ? await readJson(request) : {}; + + if (action === 'SubmitInteraction') { + state.submits.push(body); + const commandId = `fixture-command-${state.submits.length}`; + if (body.InteractionId !== APPROVAL_ID || body.ExpectedRevision !== APPROVAL_REVISION) { + sendJson(response, receipt('rejected', commandId, { + code: 'interaction_revision_conflict', + message: 'The durable interaction revision does not match.', + retryable: false, + })); + return; + } + if (state.winner) { + if (state.winner.idempotencyKey === body.IdempotencyKey) { + sendJson(response, receipt('duplicate', commandId)); + } else { + sendJson(response, receipt('rejected', commandId, { + code: 'interaction_already_resolved', + message: 'first-wins: another submission already resolved this interaction', + retryable: false, + })); + } + return; + } + state.winner = { + action: body.Action, + idempotencyKey: body.IdempotencyKey, + revision: body.ExpectedRevision, + }; + sendJson(response, receipt('accepted', commandId)); + setImmediate(() => state.continueFirstRun?.()); + return; + } + + if (action === 'RunAgent') { + state.legacyRunAgentCalls += 1; + } + const payloads = { + GetAgentUiBootstrap: bootstrap(), + ListSessions: { + Sessions: [{ + SessionId: SESSION_ID, + AgentId: AGENT_ID, + Title: 'Canonical fixture session', + UpdatedAt: '2026-08-28T00:00:00Z', + }], + Total: 1, + Page: 1, + PageSize: 30, + }, + ListAgentModels: { + Models: [ + { id: 'fixture-model', display_name: 'Fixture Model' }, + { id: 'fixture-model-alt', display_name: 'Fixture Model Alt' }, + ], + Current: 'fixture-model', + Source: 'fixture', + }, + GetSession: { Session: { SessionId: SESSION_ID, AgentId: AGENT_ID, ActiveRunStatus: '' } }, + ListSessionMessages: { Messages: [], LatestSeqId: 0, HasMore: false, NextCursor: null }, + ListSessionEvents: { Events: [], Total: 0 }, + ListSessionCheckpoints: { Checkpoints: [] }, + ListToolReceipts: { ToolReceipts: [] }, + GetResponseFeedback: null, + }; + sendJson(response, envelope(payloads[action] ?? {})); +} + +const server = createServer(async (request, response) => { + try { + const requestUrl = new URL(request.url || '/', `http://${HOST}:${PORT}`); + if (requestUrl.pathname === '/__fixture/health') { + sendJson(response, { ok: true }); + return; + } + if (requestUrl.pathname === '/__fixture/reset' && request.method === 'POST') { + state.continueFirstRun?.(); + state = createState(); + sendJson(response, { ok: true }); + return; + } + if (requestUrl.pathname === '/__fixture/config' && request.method === 'POST') { + const body = await readJson(request); + state.config = { + ...state.config, + ...(typeof body.attachmentInputs === 'boolean' + ? { attachmentInputs: body.attachmentInputs } + : {}), + }; + sendJson(response, { ok: true, config: state.config }); + return; + } + if (requestUrl.pathname === '/__fixture/state') { + sendJson(response, { + config: state.config, + inputs: state.inputs, + uploads: state.uploads, + streamPosts: state.streamPosts, + legacyRunAgentCalls: state.legacyRunAgentCalls, + reconnects: state.reconnects, + submits: state.submits, + winner: state.winner, + }); + return; + } + if (requestUrl.pathname.startsWith('/agentengine/api/v1/')) { + await handleAgentApi(request, response, requestUrl); + return; + } + if (requestUrl.pathname === `/api/v1/agents/${AGENT_ID}/conversation-surface`) { + if (requestUrl.searchParams.get('sessionId') !== SESSION_ID) { + sendJson(response, { error: 'session mismatch' }, 404); + return; + } + sendJson(response, { buildId: BUILD_ID, surface: surface() }); + return; + } + if (requestUrl.pathname === `/api/v1/builds/${BUILD_ID}/conversation:stream` + && request.method === 'POST') { + const body = await readJson(request); + state.inputs.push(body.input); + state.streamPosts += 1; + if (state.streamPosts === 1) { + await streamFirstTurn(response); + } else { + await streamSecondTurn(response); + } + return; + } + if (requestUrl.pathname === '/api/v1/runs/canonical-run-1/events') { + await streamFirstReconnect(response, requestUrl, request); + return; + } + sendJson(response, { error: `unhandled fixture route: ${requestUrl.pathname}` }, 404); + } catch (error) { + if (!response.headersSent) { + sendJson(response, { error: error instanceof Error ? error.message : String(error) }, 500); + } else if (!response.writableEnded) { + response.end(); + } + } +}); + +server.listen(PORT, HOST, () => { + process.stdout.write(`canonical conversation fixture listening on http://${HOST}:${PORT}\n`); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/e2e/fixtures/custom-conversation-consumer.html b/e2e/fixtures/custom-conversation-consumer.html new file mode 100644 index 0000000..f24e64b --- /dev/null +++ b/e2e/fixtures/custom-conversation-consumer.html @@ -0,0 +1,21 @@ + + + + + + Independent Conversation Consumer + + +
+

Independent Conversation Consumer

+
+ + + +
+

ready

+
+
+ + + diff --git a/e2e/fixtures/custom-conversation-consumer.ts b/e2e/fixtures/custom-conversation-consumer.ts new file mode 100644 index 0000000..1bfbecf --- /dev/null +++ b/e2e/fixtures/custom-conversation-consumer.ts @@ -0,0 +1,112 @@ +import { + HttpConversationClient, + buildConversationInput, + type ConversationPresentation, +} from '@kingsoftcloud/ksadk-web/conversation'; + +const AGENT_ID = 'canonical-fixture-agent'; +const SESSION_ID = 'canonical-fixture-session'; + +const composer = document.querySelector('#composer'); +const message = document.querySelector('#message'); +const status = document.querySelector('#status'); +const timeline = document.querySelector('#timeline'); + +if (!composer || !message || !status || !timeline) { + throw new Error('custom conversation fixture DOM is incomplete'); +} + +const client = new HttpConversationClient({ retryDelayMs: () => 1 }); +const presentations = new Map(); +let turn = 0; + +function node(tag: string, text: string, kind: string): HTMLElement { + const element = document.createElement(tag); + element.textContent = text; + element.dataset.kind = kind; + return element; +} + +function render(): void { + timeline.replaceChildren(); + for (const presentation of presentations.values()) { + const turnNode = document.createElement('article'); + turnNode.dataset.runId = presentation.runId; + for (const item of presentation.textItems) { + turnNode.append(node('p', item.text, item.kind)); + } + for (const item of presentation.toolItems) { + turnNode.append(node('div', String(item.payload.tool || 'Tool'), 'tool')); + } + for (const item of presentation.approvalItems) { + const approval = node( + 'div', + String(item.payload.title || item.payload.prompt || 'Approval'), + 'approval', + ); + if (item.lifecycle !== 'completed') { + const approve = node('button', 'Approve', 'approval-action') as HTMLButtonElement; + approve.type = 'button'; + const interactionId = String(item.payload.interactionId || ''); + const revision = Number(item.payload.revision); + approve.disabled = !interactionId || !Number.isInteger(revision) || revision < 1; + approve.addEventListener('click', () => { + void (async () => { + const response = await fetch('/agentengine/api/v1/SubmitInteraction', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + InteractionId: interactionId, + ExpectedRevision: revision, + Action: 'approve', + IdempotencyKey: `interaction:${interactionId}:revision-${revision}`, + }), + }); + if (!response.ok) throw new Error(`approval failed with HTTP ${response.status}`); + })().catch((error: unknown) => { + status.textContent = `failed: ${error instanceof Error ? error.message : String(error)}`; + }); + }); + approval.append(approve); + } + turnNode.append(approval); + } + for (const fallback of presentation.fallbacks) { + turnNode.append(node('div', `${fallback.title}: ${fallback.detail}`, 'fallback')); + } + timeline.append(turnNode); + } +} + +composer.addEventListener('submit', (event) => { + event.preventDefault(); + const text = message.value.trim(); + if (!text) return; + message.value = ''; + turn += 1; + const currentTurn = turn; + status.textContent = `streaming-${currentTurn}`; + void (async () => { + const bootstrap = await client.getSurface(AGENT_ID, SESSION_ID); + const input = buildConversationInput({ + inputId: `custom-input-${currentTurn}`, + sessionId: SESSION_ID, + idempotencyKey: `custom-turn-${currentTurn}`, + parts: [{ kind: 'text', text }], + modelRef: 'fixture-model', + }); + const result = await client.streamTurn({ + bootstrap, + input, + onUpdate(snapshot) { + presentations.set(snapshot.runId, snapshot.presentation); + render(); + }, + }); + presentations.set(result.runId, result.presentation); + render(); + status.textContent = `completed-${currentTurn}`; + })().catch((error: unknown) => { + status.textContent = `failed: ${error instanceof Error ? error.message : String(error)}`; + }); +}); diff --git a/e2e/interaction.spec.ts b/e2e/interaction.spec.ts index 839c7f3..abf914c 100644 --- a/e2e/interaction.spec.ts +++ b/e2e/interaction.spec.ts @@ -116,6 +116,17 @@ function resolvedEvent(interactionId = 'int-1', outcome = 'approved') { * tabs hit the same server truth. */ async function installFixture(page, state, options = {}) { + // This suite exercises the pre-ConversationSurface Interaction/v1 path. + // An explicit 404 is the only valid compatibility signal; allowing Vite's + // HTML fallback to answer 200 would correctly fail closed as bad JSON. + await page.route('**/api/v1/agents/**/conversation-surface**', async (route) => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: 'conversation surface unavailable in legacy fixture' }), + }); + }); + await page.route('**/agentengine/api/v1/**', async (route) => { const action = new URL(route.request().url()).pathname.split('/').pop(); diff --git a/package-lock.json b/package-lock.json index 2ffab9c..f6d88a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.2", + "version": "0.3.3", "license": "Apache-2.0", "dependencies": { "@ag-ui/client": "0.0.57", diff --git a/package.json b/package.json index 277028b..8fbb96a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.2", + "version": "0.3.3", "type": "module", "scripts": { "dev": "vite", @@ -12,11 +12,15 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest run src", + "test:node": "node --test tests/*.test.mjs", "test:watch": "vitest src", "test:e2e:interaction": "playwright test --config playwright.interaction.config.mjs", + "test:e2e:conversation": "playwright test --config playwright.canonical-conversation.config.mjs", "test:e2e:agui": "playwright test --config playwright.agui.config.mjs", "test:e2e:reconnect": "playwright test --config playwright.reconnect.config.mjs", - "build:ksadk": "VITE_BASE_PATH=./ vite build --outDir dist-ksadk" + "build:ksadk": "VITE_BASE_PATH=./ vite build --outDir dist-ksadk", + "release:provenance": "node scripts/release-provenance.mjs", + "release:preflight": "node scripts/release-preflight.mjs" }, "exports": { ".": { @@ -27,6 +31,10 @@ "types": "./dist-lib/public/components.d.ts", "import": "./dist-lib/components.js" }, + "./conversation": { + "types": "./dist-lib/public/conversation.d.ts", + "import": "./dist-lib/conversation.js" + }, "./runtime": { "types": "./dist-lib/public/runtime.d.ts", "import": "./dist-lib/runtime.js" @@ -47,6 +55,7 @@ "README.md", "CHANGELOG.md", "RELEASE_PROVENANCE.json", + "schemas", "LICENSE", "package.json" ], diff --git a/playwright.canonical-conversation.config.mjs b/playwright.canonical-conversation.config.mjs new file mode 100644 index 0000000..1be8577 --- /dev/null +++ b/playwright.canonical-conversation.config.mjs @@ -0,0 +1,30 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: 'canonical-conversation.spec.ts', + // The deterministic HTTP/SSE fixture models one durable session and one + // first-wins interaction ledger. Keep retries/repeats serialized so test + // cases cannot reset the same server truth concurrently. + workers: 1, + timeout: 45_000, + expect: { timeout: 10_000 }, + use: { + baseURL: 'http://127.0.0.1:4175', + viewport: { width: 1280, height: 900 }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + webServer: [ + { + command: 'node e2e/fixtures/canonical-conversation-server.mjs', + url: 'http://127.0.0.1:4182/__fixture/health', + reuseExistingServer: false, + }, + { + command: 'npm run dev -- --config vite.canonical-conversation.config.mjs --host 127.0.0.1 --port 4175 --strictPort', + url: 'http://127.0.0.1:4175', + reuseExistingServer: false, + }, + ], +}); diff --git a/schemas/release-provenance.schema.json b/schemas/release-provenance.schema.json new file mode 100644 index 0000000..1e29a6c --- /dev/null +++ b/schemas/release-provenance.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://kingsoftcloud.github.io/ksadk-web/schemas/release-provenance.schema.json", + "title": "KsADK Web release provenance", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "package", + "version", + "source_commit", + "interaction_contract_digest" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "package": { + "const": "@kingsoftcloud/ksadk-web" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + }, + "source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "interaction_contract_digest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } +} diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs new file mode 100644 index 0000000..a0dbb62 --- /dev/null +++ b/scripts/release-preflight.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkReleaseProvenance } from './release-provenance.mjs'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = resolve(SCRIPT_DIR, '..'); +export const RELEASE_COMMANDS = Object.freeze([ + ['npm', ['test']], + ['npm', ['run', 'test:node']], + ['npm', ['run', 'lint']], + ['npm', ['run', 'build:all']], + ['npm', ['run', 'test:e2e:conversation']], +]); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? REPO_ROOT, + encoding: 'utf8', + env: { ...process.env, ...options.env }, + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + if (result.status !== 0) { + const detail = options.capture + ? `\n${result.stdout ?? ''}${result.stderr ?? ''}` + : ''; + throw new Error(`${command} ${args.join(' ')} failed with exit ${result.status}${detail}`); + } + return result.stdout ?? ''; +} + +function assertCleanWorktree({ allowDirty }) { + if (allowDirty) return; + const result = spawnSync('git', ['status', '--porcelain', '--untracked-files=normal'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }); + if (result.status !== 0) throw new Error('unable to inspect Git worktree'); + if (result.stdout.trim()) { + throw new Error('formal release preflight requires a clean worktree'); + } +} + +async function packAndVerify() { + const tempRoot = await mkdtemp(resolve(tmpdir(), 'ksadk-web-release-')); + try { + const packOutput = run( + 'npm', + // build:all above already exercises the package's complete build surface. + // Suppress lifecycle scripts here so npm's JSON artifact manifest remains + // machine-readable instead of being prefixed by Vite reporter output. + ['pack', '--ignore-scripts', '--json', '--access', 'public', '--pack-destination', tempRoot], + { capture: true }, + ); + const packResult = JSON.parse(packOutput)[0]; + if (!packResult?.filename || !Array.isArray(packResult.files)) { + throw new Error('npm pack did not return a structured artifact manifest'); + } + const packedPaths = new Set(packResult.files.map((entry) => entry.path)); + for (const requiredPath of [ + 'dist-lib/conversation.js', + 'dist-lib/public/conversation.d.ts', + 'dist-ksadk/index.html', + 'RELEASE_PROVENANCE.json', + 'schemas/release-provenance.schema.json', + 'CHANGELOG.md', + ]) { + if (!packedPaths.has(requiredPath)) { + throw new Error(`packed artifact is missing ${requiredPath}`); + } + } + + const consumerRoot = resolve(tempRoot, 'consumer'); + await mkdir(consumerRoot, { recursive: true }); + await writeFile( + resolve(consumerRoot, 'package.json'), + `${JSON.stringify({ private: true, type: 'module' }, null, 2)}\n`, + 'utf8', + ); + + const tarball = resolve(tempRoot, basename(packResult.filename)); + run( + 'npm', + ['install', '--ignore-scripts', '--no-package-lock', '--no-audit', '--no-fund', tarball], + { cwd: consumerRoot }, + ); + await cp( + resolve(REPO_ROOT, 'scripts/verify-packed-conversation.mjs'), + resolve(consumerRoot, 'verify-packed-conversation.mjs'), + ); + run('node', ['verify-packed-conversation.mjs'], { cwd: consumerRoot }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +function parseFlags(args) { + return { + allowDirty: args.includes('--allow-dirty'), + allowUnreleased: args.includes('--allow-unreleased'), + }; +} + +export async function main(args = process.argv.slice(2)) { + const flags = parseFlags(args); + assertCleanWorktree(flags); + const provenance = await checkReleaseProvenance({ + repoRoot: REPO_ROOT, + allowUnreleased: flags.allowUnreleased, + }); + console.log( + `verified release provenance ${provenance.package}@${provenance.version} ` + + `(source ${provenance.sourceCommit})`, + ); + for (const [command, commandArgs] of RELEASE_COMMANDS) { + run(command, commandArgs); + } + await packAndVerify(); + console.log('ksadk-web release preflight passed'); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/release-provenance.mjs b/scripts/release-provenance.mjs new file mode 100644 index 0000000..f3e469a --- /dev/null +++ b/scripts/release-provenance.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +export const DEFAULT_REPO_ROOT = resolve(SCRIPT_DIR, '..'); + +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const COMMIT_PATTERN = /^[0-9a-f]{40}$/; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const EXPECTED_KEYS = [ + 'interaction_contract_digest', + 'package', + 'schema_version', + 'source_commit', + 'version', +]; + +function git(repoRoot, args, options = {}) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function gitSucceeds(repoRoot, args) { + try { + git(repoRoot, args); + return true; + } catch { + return false; + } +} + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +export function validateReleaseProvenance(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('release provenance must be a JSON object'); + } + const keys = Object.keys(value).sort(); + if (JSON.stringify(keys) !== JSON.stringify(EXPECTED_KEYS)) { + throw new Error(`release provenance keys must be exactly: ${EXPECTED_KEYS.join(', ')}`); + } + if (value.schema_version !== 1) { + throw new Error('release provenance schema_version must be 1'); + } + if (value.package !== '@kingsoftcloud/ksadk-web') { + throw new Error('release provenance package is not @kingsoftcloud/ksadk-web'); + } + if (!VERSION_PATTERN.test(value.version)) { + throw new Error('release provenance version is not valid SemVer'); + } + if (!COMMIT_PATTERN.test(value.source_commit)) { + throw new Error('release provenance source_commit must be a full lowercase Git SHA'); + } + if (!DIGEST_PATTERN.test(value.interaction_contract_digest)) { + throw new Error('release provenance interaction_contract_digest must be a SHA-256 digest'); + } + return value; +} + +function readJsonAtCommit(repoRoot, commit, path) { + return JSON.parse(git(repoRoot, ['show', `${commit}:${path}`])); +} + +export async function checkReleaseProvenance({ + repoRoot = DEFAULT_REPO_ROOT, + allowUnreleased = false, +} = {}) { + const packageJson = await readJson(resolve(repoRoot, 'package.json')); + const provenance = validateReleaseProvenance( + await readJson(resolve(repoRoot, 'RELEASE_PROVENANCE.json')), + ); + + if (provenance.package !== packageJson.name || provenance.version !== packageJson.version) { + throw new Error( + `RELEASE_PROVENANCE.json identifies ${provenance.package}@${provenance.version}, ` + + `but package.json identifies ${packageJson.name}@${packageJson.version}`, + ); + } + if (!gitSucceeds(repoRoot, ['cat-file', '-e', `${provenance.source_commit}^{commit}`])) { + throw new Error(`provenance source commit does not exist: ${provenance.source_commit}`); + } + if (!gitSucceeds(repoRoot, ['merge-base', '--is-ancestor', provenance.source_commit, 'HEAD'])) { + throw new Error('provenance source commit is not an ancestor of HEAD'); + } + + const sourcePackage = readJsonAtCommit(repoRoot, provenance.source_commit, 'package.json'); + if (sourcePackage.name !== provenance.package || sourcePackage.version !== provenance.version) { + throw new Error('provenance source commit does not contain the declared package identity'); + } + + const tag = `v${packageJson.version}`; + const tagExists = gitSucceeds(repoRoot, ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`]); + const head = git(repoRoot, ['rev-parse', 'HEAD']); + let currentAheadOfPublishedTag = false; + + if (tagExists) { + const tagCommit = git(repoRoot, ['rev-parse', `${tag}^{commit}`]); + currentAheadOfPublishedTag = head !== tagCommit; + const taggedProvenance = readJsonAtCommit(repoRoot, tag, 'RELEASE_PROVENANCE.json'); + if (JSON.stringify(taggedProvenance) !== JSON.stringify(provenance)) { + throw new Error(`${tag} is immutable, but RELEASE_PROVENANCE.json no longer matches the tag`); + } + if (currentAheadOfPublishedTag && !allowUnreleased) { + throw new Error( + `${packageJson.name}@${packageJson.version} is already tagged at ${tag}; ` + + 'bump the patch version before a formal release preflight', + ); + } + } else if (!allowUnreleased) { + const changedSinceSource = git(repoRoot, [ + 'diff', '--name-only', provenance.source_commit, 'HEAD', '--', + ]).split('\n').filter(Boolean); + const nonAttestationChanges = changedSinceSource.filter( + (path) => path !== 'RELEASE_PROVENANCE.json', + ); + if (nonAttestationChanges.length > 0) { + throw new Error( + 'formal provenance source is not the frozen code commit; changes after it: ' + + nonAttestationChanges.join(', '), + ); + } + } + + return { + package: provenance.package, + version: provenance.version, + sourceCommit: provenance.source_commit, + tag, + tagExists, + currentAheadOfPublishedTag, + }; +} + +export async function generateReleaseProvenance({ + repoRoot = DEFAULT_REPO_ROOT, + interactionContractDigest, +} = {}) { + const dirty = git(repoRoot, ['status', '--porcelain', '--untracked-files=normal']); + if (dirty) { + throw new Error('refusing to generate formal provenance from a dirty worktree'); + } + + const packageJson = await readJson(resolve(repoRoot, 'package.json')); + if (!VERSION_PATTERN.test(packageJson.version)) { + throw new Error(`package.json version is not valid SemVer: ${packageJson.version}`); + } + const tag = `v${packageJson.version}`; + if (gitSucceeds(repoRoot, ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`])) { + throw new Error(`refusing to re-sign already tagged version ${tag}`); + } + + let digest = interactionContractDigest; + if (!digest) { + const existing = validateReleaseProvenance( + await readJson(resolve(repoRoot, 'RELEASE_PROVENANCE.json')), + ); + digest = existing.interaction_contract_digest; + } + if (!DIGEST_PATTERN.test(digest)) { + throw new Error('interaction contract digest must be a lowercase SHA-256 digest'); + } + + const provenance = validateReleaseProvenance({ + schema_version: 1, + package: packageJson.name, + version: packageJson.version, + source_commit: git(repoRoot, ['rev-parse', 'HEAD']), + interaction_contract_digest: digest, + }); + await writeFile( + resolve(repoRoot, 'RELEASE_PROVENANCE.json'), + `${JSON.stringify(provenance, null, 2)}\n`, + 'utf8', + ); + return provenance; +} + +function parseFlags(args) { + return { + command: args.find((arg) => !arg.startsWith('--')) ?? 'check', + allowUnreleased: args.includes('--allow-unreleased'), + interactionContractDigest: args.find((arg) => arg.startsWith('--interaction-contract-digest=')) + ?.slice('--interaction-contract-digest='.length), + }; +} + +export async function main(args = process.argv.slice(2)) { + const flags = parseFlags(args); + if (flags.command === 'check') { + const result = await checkReleaseProvenance({ allowUnreleased: flags.allowUnreleased }); + console.log( + `release provenance valid: ${result.package}@${result.version} ` + + `source=${result.sourceCommit}`, + ); + return; + } + if (flags.command === 'generate') { + const result = await generateReleaseProvenance({ + interactionContractDigest: flags.interactionContractDigest, + }); + console.log( + `generated formal provenance for ${result.package}@${result.version} ` + + `from frozen commit ${result.source_commit}`, + ); + return; + } + throw new Error('usage: release-provenance.mjs check [--allow-unreleased] | generate [--interaction-contract-digest=]'); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/verify-packed-conversation.mjs b/scripts/verify-packed-conversation.mjs new file mode 100644 index 0000000..cdb929e --- /dev/null +++ b/scripts/verify-packed-conversation.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { + ConversationItemReducer, + HttpConversationClient, + buildConversationInput, + decodeConversationInput, + decodeConversationItem, + decodeConversationSurface, + projectConversationItems, +} from '@kingsoftcloud/ksadk-web/conversation'; + +assert.equal(typeof HttpConversationClient, 'function'); +assert.equal(typeof ConversationItemReducer, 'function'); + +const input = buildConversationInput({ + inputId: 'packed-input-1', + sessionId: 'packed-session-1', + idempotencyKey: 'packed-turn-1', + parts: [{ kind: 'text', text: 'hello from packed consumer' }], + modelRef: 'packed-model', +}); +assert.deepEqual(decodeConversationInput(input), input); + +const surface = decodeConversationSurface({ + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationSurface', + surfaceId: 'packed-surface-1', + sessionId: 'packed-session-1', + providerRef: 'packed-provider-1', + inputs: [ + { name: 'text', mode: 'native' }, + { name: 'model.select', mode: 'native' }, + ], + outputs: [{ name: 'text', mode: 'native' }], +}); +assert.equal(surface?.apiVersion, 'conversation.ksadk.io/v1'); + +const item = decodeConversationItem({ + apiVersion: 'conversation.ksadk.io/v1', + kindVersion: 1, + itemId: 'packed-item-1', + sourceEventIds: ['packed-event-1'], + sessionId: 'packed-session-1', + runId: 'packed-run-1', + kind: 'assistant_text', + lifecycle: 'completed', + operation: 'completed', + visibility: 'public', + payloadSchemaRef: 'conversation.item.assistant_text/v1', + payload: { text: 'packed response' }, + nativeRef: {}, +}); +assert.ok(item); +const reducer = new ConversationItemReducer(); +reducer.apply(item); +assert.equal( + projectConversationItems(reducer.snapshot()).textItems[0]?.text, + 'packed response', +); + +console.log('packed conversation public API verified'); diff --git a/src/__tests__/conversation-client.test.ts b/src/__tests__/conversation-client.test.ts new file mode 100644 index 0000000..a185e6d --- /dev/null +++ b/src/__tests__/conversation-client.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + ConversationClientError, + HttpConversationClient, + buildConversationInput, + decodeConversationInput, + preflightConversationInput, + type ConversationSurface, +} from '../public/conversation.js'; + +const SURFACE: ConversationSurface = { + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationSurface', + surfaceId: 'surface-1', + sessionId: 'session-1', + providerRef: 'provider-1', + inputs: [ + { name: 'text', mode: 'native' }, + { name: 'attachment.image', mode: 'translated' }, + { name: 'model.select', mode: 'native' }, + ], + outputs: [{ name: 'streaming', mode: 'native' }], +}; + +function input() { + return buildConversationInput({ + inputId: 'input-1', + sessionId: 'session-1', + idempotencyKey: 'turn-1', + parts: [{ kind: 'text', text: 'hello' }], + modelRef: 'model:example', + extensions: {}, + }); +} + +function item( + sourceEventId: string, + text: string, + overrides: Record = {}, +) { + return { + apiVersion: 'conversation.ksadk.io/v1', + kindVersion: 1, + itemId: 'answer-1', + sourceEventIds: [sourceEventId], + sessionId: 'session-1', + runId: 'run-1', + kind: 'assistant_text', + operation: 'append', + lifecycle: 'streaming', + visibility: 'public', + payloadSchemaRef: 'conversation.item.assistant_text/v1', + payload: { text }, + nativeRef: {}, + ...overrides, + }; +} + +function frame(id: number, type: string, conversationItem: unknown): string { + return `id: ${id}\nevent: ${type}\ndata: ${JSON.stringify({ conversationItem })}\n\n`; +} + +function stream(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { 'Content-Type': 'text/event-stream' }, + }); +} + +describe('ConversationInput/v1', () => { + it('builds and decodes the frozen provider-neutral input fixture', () => { + const built = buildConversationInput({ + inputId: 'input-example', + sessionId: 'session-example', + idempotencyKey: 'turn-example', + parts: [ + { kind: 'text', text: 'describe this image' }, + { + kind: 'attachment', + attachmentRef: 'attachment://image-example', + mediaType: 'image/png', + name: 'example.png', + }, + ], + modelRef: 'model:example', + reasoning: 'high', + extensions: { + 'ksadk.approval': 'risk', + 'ksadk.collaboration': 'plan', + 'ksadk.goal': 'finish the task', + 'vendor.preview': true, + }, + }); + + expect(built).toMatchObject({ + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationInput', + parts: expect.any(Array), + }); + expect(decodeConversationInput(built)).toEqual(built); + }); + + it('rejects unknown or provider-specific fields instead of forwarding them', () => { + expect(decodeConversationInput({ + ...input(), + apiKey: 'must-not-pass', + })).toBeNull(); + expect(() => buildConversationInput({ + inputId: 'bad', + sessionId: 'session-1', + idempotencyKey: 'bad', + parts: [{ kind: 'text', text: 'hello' }], + extensions: { unnamespaced: true }, + })).toThrowError(expect.objectContaining({ code: 'conversation_contract_mismatch' })); + }); + + it('preflights session and every optional input against the active surface', () => { + expect(preflightConversationInput(SURFACE, input())).toEqual(input()); + expect(() => preflightConversationInput( + { ...SURFACE, inputs: [{ name: 'text', mode: 'native' }] }, + input(), + )).toThrowError(expect.objectContaining({ + code: 'conversation_input_unsupported', + capability: 'model.select', + })); + expect(() => preflightConversationInput( + SURFACE, + { ...input(), sessionId: 'other-session' }, + )).toThrowError(expect.objectContaining({ code: 'conversation_session_mismatch' })); + }); +}); + +describe('HttpConversationClient', () => { + it('gets a typed surface without adding credential-bearing request options', async () => { + const fetcher = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.credentials).toBeUndefined(); + expect(init?.headers).toBeUndefined(); + return new Response(JSON.stringify({ buildId: 'build-1', surface: SURFACE }), { + headers: { 'Content-Type': 'application/json' }, + }); + }); + const client = new HttpConversationClient({ fetch: fetcher }); + + await expect(client.getSurface('agent-1', 'session-1')).resolves.toEqual({ + buildId: 'build-1', + surface: SURFACE, + }); + }); + + it('POSTs once, then resumes by cursor and canonical item run id', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fetcher = vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + if (url === '/api/v1/builds/build-1/conversation:stream') { + return stream(frame(1, 'message.delta', item('source-1', 'hello'))); + } + if (url === '/api/v1/runs/run-1/events?after=1') { + return stream([ + frame(1, 'message.delta', item('source-1', 'hello')), + frame(2, 'message.delta', item('source-2', ' world')), + frame(3, 'run.completed', item('source-3', '', { + itemId: 'run-end', + kind: 'progress', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.progress/v1', + payload: {}, + })), + ].join('')); + } + throw new Error(`unexpected URL: ${url}`); + }); + const client = new HttpConversationClient({ + fetch: fetcher, + maxReconnects: 2, + sleep: async () => {}, + }); + + const result = await client.streamTurn({ + bootstrap: { buildId: 'build-1', surface: SURFACE }, + input: input(), + }); + + expect(calls.filter((call) => call.init?.method === 'POST')).toHaveLength(1); + expect(calls.map((call) => call.url)).toEqual([ + '/api/v1/builds/build-1/conversation:stream', + '/api/v1/runs/run-1/events?after=1', + ]); + expect(calls[0]?.init).toMatchObject({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'turn-1', + }, + }); + expect(calls[0]?.init?.credentials).toBeUndefined(); + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ input: input() }); + expect(calls[1]?.init).toMatchObject({ + method: 'GET', + headers: { 'Last-Event-ID': '1' }, + }); + expect(result.cursor).toBe(3); + expect(result.runId).toBe('run-1'); + expect(result.presentation.textItems[0]?.text).toBe('hello world'); + expect(result.presentation.terminalStatus).toBe('completed'); + }); + + it('fails typed when a stream ends before a canonical item supplies run identity', async () => { + const fetcher = vi.fn(async () => stream( + 'id: 1\nevent: run.created\ndata: {"runId":"not-authoritative-here"}\n\n', + )); + const client = new HttpConversationClient({ fetch: fetcher, sleep: async () => {} }); + + await expect(client.streamTurn({ + bootstrap: { buildId: 'build-1', surface: SURFACE }, + input: input(), + })).rejects.toMatchObject({ code: 'conversation_run_identity_missing' }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('stops at the retry limit without ever repeating the POST', async () => { + const fetcher = vi.fn(async (url: string) => ( + url.includes('conversation:stream') + ? stream(frame(1, 'message.delta', item('source-1', 'partial'))) + : stream('') + )); + const client = new HttpConversationClient({ + fetch: fetcher, + maxReconnects: 2, + sleep: async () => {}, + }); + + await expect(client.streamTurn({ + bootstrap: { buildId: 'build-1', surface: SURFACE }, + input: input(), + })).rejects.toMatchObject({ + code: 'conversation_reconnect_exhausted', + runId: 'run-1', + cursor: 1, + }); + expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it('reports abort and HTTP failures as typed errors', async () => { + const controller = new AbortController(); + controller.abort(); + const client = new HttpConversationClient({ fetch: vi.fn(), sleep: async () => {} }); + await expect(client.streamTurn({ + bootstrap: { buildId: 'build-1', surface: SURFACE }, + input: input(), + signal: controller.signal, + })).rejects.toMatchObject({ code: 'conversation_aborted' }); + + const failed = new HttpConversationClient({ + fetch: vi.fn(async () => new Response('unavailable', { status: 503 })), + }); + await expect(failed.getSurface('agent-1', 'session-1')).rejects.toEqual( + expect.objectContaining>({ + code: 'conversation_http_error', + status: 503, + }), + ); + }); + + it('aborts while waiting to reconnect instead of starting another request', async () => { + const controller = new AbortController(); + let notifySleepStarted = () => {}; + const sleepStarted = new Promise((resolve) => { + notifySleepStarted = resolve; + }); + const fetcher = vi.fn(async () => stream( + frame(1, 'message.delta', item('source-1', 'partial')), + )); + const client = new HttpConversationClient({ + fetch: fetcher, + sleep: async () => { + notifySleepStarted(); + await new Promise(() => {}); + }, + }); + const turn = client.streamTurn({ + bootstrap: { buildId: 'build-1', surface: SURFACE }, + input: input(), + signal: controller.signal, + }); + await sleepStarted; + controller.abort(); + + await expect(turn).rejects.toMatchObject({ code: 'conversation_aborted' }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/conversation-protocol.test.ts b/src/__tests__/conversation-protocol.test.ts new file mode 100644 index 0000000..fc97817 --- /dev/null +++ b/src/__tests__/conversation-protocol.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest'; + +import { + ConversationItemReducer, + createConversationItemState, + decodeConversationItem, + decodeConversationSurface, + projectConversationItems, + reduceConversationItem, + surfacePermitsInput, + type ConversationItem, +} from '../core/conversation/index.js'; + +function item(overrides: Record = {}): Record { + return { + apiVersion: 'conversation.ksadk.io/v1', + kindVersion: 1, + itemId: 'item-1', + sourceEventIds: ['event-1'], + sessionId: 'session-1', + runId: 'run-1', + kind: 'assistant_text', + operation: 'append', + lifecycle: 'streaming', + visibility: 'public', + payloadSchemaRef: 'conversation.item.assistant_text/v1', + payload: { text: 'same text' }, + nativeRef: {}, + ...overrides, + }; +} + +function decodedItem(overrides: Record = {}): ConversationItem { + const decoded = decodeConversationItem(item(overrides)); + if (!decoded) throw new Error('test fixture did not decode'); + return decoded; +} + +describe('ConversationSurface/v1', () => { + it('decodes the frozen fixture shape and never guesses unavailable inputs', () => { + const surface = decodeConversationSurface({ + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationSurface', + surfaceId: 'studio.conversation', + sessionId: 'session-example', + providerRef: 'runtime:codex', + inputs: [ + { name: 'text', mode: 'native' }, + { name: 'attachment.image', mode: 'translated' }, + { name: 'goal', mode: 'unavailable', reason: 'not supported' }, + ], + outputs: [{ name: 'text', mode: 'native' }], + }); + + expect(surface).not.toBeNull(); + expect(surfacePermitsInput(surface!, 'text')).toBe(true); + expect(surfacePermitsInput(surface!, 'attachment.image', 'attachment.file')).toBe(true); + expect(surfacePermitsInput(surface!, 'goal')).toBe(false); + expect(surfacePermitsInput(surface!, 'plan')).toBe(false); + }); + + it('applies contract defaults but rejects duplicate or dishonest capabilities', () => { + const minimal = { + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationSurface', + surfaceId: 'surface-1', + sessionId: 'session-1', + providerRef: 'provider-1', + }; + expect(decodeConversationSurface(minimal)).toMatchObject({ inputs: [], outputs: [] }); + expect(decodeConversationSurface({ + ...minimal, + inputs: [{ name: 'text', mode: 'native' }, { name: 'text', mode: 'translated' }], + })).toBeNull(); + expect(decodeConversationSurface({ + ...minimal, + inputs: [{ name: 'goal', mode: 'unavailable' }], + })).toBeNull(); + expect(decodeConversationSurface({ + ...minimal, + inputs: [{ name: 'Not Namespaced', mode: 'native' }], + })).toBeNull(); + }); +}); + +describe('ConversationItem/v1 identity reducer', () => { + it('preserves equal text from distinct items and ignores reconnect replay', () => { + const first = decodedItem(); + const second = decodedItem({ itemId: 'item-2', sourceEventIds: ['event-2'] }); + let state = createConversationItemState(); + state = reduceConversationItem(state, first); + const afterFirst = state; + state = reduceConversationItem(state, first); + expect(state).toBe(afterFirst); + state = reduceConversationItem(state, second); + + expect(projectConversationItems(state).textItems).toEqual([ + expect.objectContaining({ id: 'item-1', text: 'same text' }), + expect.objectContaining({ id: 'item-2', text: 'same text' }), + ]); + }); + + it('merges a new delta by item identity and source event identity', () => { + const reducer = new ConversationItemReducer(); + expect(reducer.apply(decodedItem({ payload: { text: 'hello' } }))).toBe(true); + expect(reducer.apply(decodedItem({ payload: { text: 'hello' } }))).toBe(false); + expect(reducer.apply(decodedItem({ + sourceEventIds: ['event-2'], + payload: { text: ' world' }, + }))).toBe(true); + const presentation = projectConversationItems(reducer.snapshot()); + expect(presentation.textItems[0]?.text) + .toBe('hello world'); + expect(presentation.output).toBe('hello world'); + expect(presentation.reasoning).toBe(''); + }); + + it('keeps a terminal snapshot monotonic when an older delta reconnects late', () => { + const completed = decodedItem({ + sourceEventIds: ['event-terminal'], + operation: 'completed', + lifecycle: 'completed', + payload: { text: 'final' }, + }); + const stale = decodedItem({ + sourceEventIds: ['event-stale'], + payload: { text: ' stale' }, + }); + let state = reduceConversationItem(createConversationItemState(), completed); + state = reduceConversationItem(state, stale); + + expect(state.items[0]?.lifecycle).toBe('completed'); + expect(projectConversationItems(state).textItems[0]?.text).toBe('final'); + expect(state.appliedSources).toContain(JSON.stringify(['item-1', 'event-stale'])); + }); + + it('does not collide when item or source identifiers contain delimiters', () => { + const first = decodedItem({ + itemId: 'item', + sourceEventIds: ['source\u0000tail'], + payload: { text: 'first' }, + }); + const second = decodedItem({ + itemId: 'item\u0000source', + sourceEventIds: ['tail'], + payload: { text: 'second' }, + }); + let state = reduceConversationItem(createConversationItemState(), first); + state = reduceConversationItem(state, second); + + expect(projectConversationItems(state).textItems.map((entry) => entry.text)) + .toEqual(['first', 'second']); + }); + + it('rejects structurally invalid terminal operations', () => { + expect(decodeConversationItem(item({ + operation: 'completed', + lifecycle: 'streaming', + }))).toBeNull(); + expect(decodeConversationItem(item({ sourceEventIds: ['event-1', 'event-1'] }))) + .toBeNull(); + }); +}); + +describe('ConversationItem/v1 renderer projection', () => { + it('keeps stream order and enriches a tool call with a separate result item', () => { + let state = createConversationItemState(); + state = reduceConversationItem(state, decodedItem({ + itemId: 'reasoning-1', + sourceEventIds: ['reasoning-event'], + kind: 'reasoning', + payloadSchemaRef: 'conversation.item.reasoning/v1', + payload: { text: 'inspect workspace' }, + })); + state = reduceConversationItem(state, decodedItem({ + itemId: 'tool-call-item', + sourceEventIds: ['tool-call-event'], + kind: 'tool_call', + payloadSchemaRef: 'conversation.item.tool-call/v1', + payload: { callId: 'call-1', tool: 'shell', args: { command: 'pwd' } }, + })); + state = reduceConversationItem(state, decodedItem({ + itemId: 'tool-result-item', + sourceEventIds: ['tool-result-event'], + kind: 'tool_call', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.tool-call/v1', + payload: { callId: 'call-1', output: { stdout: '/workspace' } }, + })); + state = reduceConversationItem(state, decodedItem({ + itemId: 'answer-1', + sourceEventIds: ['answer-event'], + payload: { text: 'Workspace inspected.' }, + })); + + const presentation = projectConversationItems(state); + expect(presentation.timeline.map((entry) => entry.key)).toEqual([ + 'item:reasoning-1', + 'tool:call-1', + 'item:answer-1', + ]); + expect(presentation.timeline[1]).toMatchObject({ + sourceItemIds: ['tool-call-item', 'tool-result-item'], + item: { + itemId: 'tool-call-item', + lifecycle: 'completed', + payload: { + tool: 'shell', + output: { stdout: '/workspace' }, + }, + }, + }); + }); + + it('hides future kinds and degrades payload schemas without executing their payload', () => { + const unknownKind = decodedItem({ + itemId: 'future-kind', + sourceEventIds: ['future-event'], + kind: 'game_board', + payloadSchemaRef: 'vendor.game-board/v7', + payload: { html: '' }, + }); + const unknownSchema = decodedItem({ + itemId: 'future-schema', + sourceEventIds: ['future-schema-event'], + payloadSchemaRef: 'conversation.item.assistant_text/v99', + }); + let state = createConversationItemState(); + state = reduceConversationItem(state, unknownKind); + state = reduceConversationItem(state, unknownSchema); + const presentation = projectConversationItems(state); + + expect(presentation.textItems).toEqual([]); + expect(unknownKind.visibility).toBe('hidden'); + expect(presentation.fallbacks).toEqual([ + expect.objectContaining({ id: 'future-schema', title: 'Unsupported content' }), + ]); + expect(unknownKind.payload).not.toHaveProperty('html'); + }); + + it.each([ + ['javascript:alert(1)', null], + ['data:text/html,bad', null], + ['file:///tmp/secret', null], + ['https://user:secret@example.com/report', null], + ['https://example.com/report.md', 'https://example.com/report.md'], + ])('sanitizes artifact URI %s', (uri, expected) => { + const artifact = decodedItem({ + kind: 'artifact', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.artifact/v1', + payload: { name: 'report.md', mimeType: 'text/markdown', uri }, + }); + const state = reduceConversationItem(createConversationItemState(), artifact); + expect(projectConversationItems(state).artifacts[0]?.uri).toBe(expected); + }); + + it('omits internal and hidden items unless internal rendering is explicit', () => { + let state = createConversationItemState(); + state = reduceConversationItem(state, decodedItem({ + itemId: 'public', + sourceEventIds: ['public-event'], + payload: { text: 'public' }, + })); + state = reduceConversationItem(state, decodedItem({ + itemId: 'internal', + sourceEventIds: ['internal-event'], + visibility: 'internal', + payload: { text: 'internal' }, + })); + state = reduceConversationItem(state, decodedItem({ + itemId: 'hidden', + sourceEventIds: ['hidden-event'], + visibility: 'hidden', + payload: { text: 'hidden' }, + })); + + expect(projectConversationItems(state).textItems.map((entry) => entry.text)) + .toEqual(['public']); + expect(projectConversationItems(state, { includeInternal: true }).textItems + .map((entry) => entry.text)).toEqual(['public', 'internal']); + }); +}); diff --git a/src/__tests__/conversation-renderer-registry.test.ts b/src/__tests__/conversation-renderer-registry.test.ts new file mode 100644 index 0000000..720179a --- /dev/null +++ b/src/__tests__/conversation-renderer-registry.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { + createTrustedRendererCatalog, + type ConversationItem, +} from '../core/conversation/index.js'; + +const item: ConversationItem = { + apiVersion: 'conversation.ksadk.io/v1', + kindVersion: 1, + itemId: 'tool-1', + sourceEventIds: ['event-1'], + sessionId: 'session-1', + runId: 'run-1', + kind: 'tool_call', + operation: 'append', + lifecycle: 'streaming', + visibility: 'public', + payloadSchemaRef: 'conversation.item.tool-call/v1', + payload: {}, + nativeRef: {}, +}; + +describe('trusted conversation renderer catalog', () => { + it('matches an exact schema and kind, never a future version', () => { + const catalog = createTrustedRendererCatalog([{ + id: 'core.tool-call', + schemaRef: 'conversation.item.tool-call/v1', + kinds: ['tool_call'], + }]); + + expect(catalog.resolve(item)?.id).toBe('core.tool-call'); + expect(catalog.resolve({ ...item, payloadSchemaRef: 'conversation.item.tool-call/v99' })) + .toBeUndefined(); + expect(catalog.resolve({ ...item, kind: 'assistant_text' })) + .toBeUndefined(); + }); + + it('rejects duplicate schema ownership at host construction', () => { + expect(() => createTrustedRendererCatalog([ + { id: 'one', schemaRef: 'vendor.card/v1', kinds: ['unknown'] }, + { id: 'two', schemaRef: 'vendor.card/v1', kinds: ['unknown'] }, + ])).toThrow('duplicate trusted conversation renderer schemaRef'); + }); +}); diff --git a/src/__tests__/hosted-conversation.test.ts b/src/__tests__/hosted-conversation.test.ts new file mode 100644 index 0000000..d26d9f1 --- /dev/null +++ b/src/__tests__/hosted-conversation.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + HttpConversationClient, + buildConversationInput, + type ConversationSurface, +} from '../public/conversation.js'; +import { dispatchRunEventToStores } from '../core/run/dispatcher.js'; +import { sharedInteractionStore } from '../core/interaction/index.js'; +import { useMessageStore } from '../stores/message.js'; +import { useSessionStore } from '../stores/session.js'; + +const SURFACE: ConversationSurface = { + apiVersion: 'conversation.ksadk.io/v1', + kind: 'ConversationSurface', + surfaceId: 'hosted-conversation', + sessionId: 'session-hosted', + providerRef: 'provider:test', + inputs: [{ name: 'text', mode: 'native' }], + outputs: [ + { name: 'text', mode: 'native' }, + { name: 'reasoning', mode: 'native' }, + { name: 'tool.inspect', mode: 'native' }, + { name: 'approval', mode: 'native' }, + { name: 'a2ui', mode: 'native' }, + ], +}; + +function item( + itemId: string, + sourceEventId: string, + kind: string, + payloadSchemaRef: string, + payload: Record, + overrides: Record = {}, +) { + return { + apiVersion: 'conversation.ksadk.io/v1', + kindVersion: 1, + itemId, + sourceEventIds: [sourceEventId], + sessionId: 'session-hosted', + runId: 'run-hosted', + kind, + operation: 'append', + lifecycle: 'streaming', + visibility: 'public', + payloadSchemaRef, + payload, + nativeRef: {}, + ...overrides, + }; +} + +function frame(id: number, conversationItem: unknown): string { + return `id: ${id}\ndata: ${JSON.stringify({ conversationItem })}\n\n`; +} + +function stream(body: string): Response { + return new Response(body, { + headers: { 'Content-Type': 'text/event-stream' }, + }); +} + +describe('Hosted UI canonical ConversationItem projection', () => { + it('uses one identity reducer across reconnect for text, reasoning, tool, approval and A2UI', async () => { + const operations = [{ + version: 'v0.9', + createSurface: { surfaceId: 'profile-form', catalogId: 'basic' }, + }]; + const initialText = item( + 'answer-1', + 'event-1', + 'assistant_text', + 'conversation.item.assistant_text/v1', + { text: 'same text' }, + ); + const fetcher = vi.fn(async (url: string) => { + if (url.includes('conversation:stream')) { + return stream(frame(1, initialText)); + } + if (url === '/api/v1/runs/run-hosted/events?after=1') { + return stream([ + // The reconnect boundary replays the last source event. The shared + // reducer, not the Hosted UI, owns replay idempotence. + frame(1, initialText), + frame(2, item( + 'answer-2', + 'event-2', + 'assistant_text', + 'conversation.item.assistant_text/v1', + { text: 'same text' }, + )), + frame(3, item( + 'reasoning-1', + 'event-3', + 'reasoning', + 'conversation.item.reasoning/v1', + { text: 'inspect the workspace' }, + )), + frame(4, item( + 'tool-1', + 'event-4', + 'tool_call', + 'conversation.item.tool-call/v1', + { + callId: 'call-1', + tool: 'read_file', + args: { path: 'README.md' }, + }, + )), + frame(5, item( + 'tool-result-1', + 'event-5', + 'tool_call', + 'conversation.item.tool-call/v1', + { + callId: 'call-1', + output: { ok: true }, + }, + { operation: 'completed', lifecycle: 'completed' }, + )), + frame(6, item( + 'approval-item-1', + 'event-6', + 'approval', + 'conversation.item.approval/v1', + { + interactionId: 'approval-1', + revision: 2, + kind: 'command', + prompt: 'Allow command?', + detail: { command: 'echo safe' }, + }, + { lifecycle: 'pending' }, + )), + frame(7, item( + 'a2ui-1', + 'event-7', + 'a2ui', + 'conversation.item.a2ui/v1', + { data: operations }, + )), + frame(8, item( + 'future-1', + 'event-8', + 'game_board', + 'vendor.game-board/v7', + { html: '' }, + )), + frame(9, item( + 'run-terminal', + 'event-9', + 'progress', + 'conversation.item.progress/v1', + {}, + { operation: 'completed', lifecycle: 'completed' }, + )), + ].join('')); + } + throw new Error(`unexpected URL ${url}`); + }); + const client = new HttpConversationClient({ + fetch: fetcher, + maxReconnects: 1, + sleep: async () => {}, + }); + useSessionStore.getState().setCurrentSessionId('session-hosted'); + useMessageStore.getState().setMessages([]); + sharedInteractionStore.clearSession('session-hosted'); + + const result = await client.streamTurn({ + bootstrap: { buildId: 'build-hosted', surface: SURFACE }, + input: buildConversationInput({ + inputId: 'input-hosted', + sessionId: 'session-hosted', + idempotencyKey: 'turn-hosted', + parts: [{ kind: 'text', text: 'hello' }], + }), + onUpdate: (snapshot) => dispatchRunEventToStores({ + type: 'conversation_snapshot', + result: snapshot, + sessionId: 'session-hosted', + }), + }); + + expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1); + expect(result.state.items.map((entry) => entry.itemId)).toEqual([ + 'answer-1', + 'answer-2', + 'reasoning-1', + 'tool-1', + 'tool-result-1', + 'approval-item-1', + 'a2ui-1', + 'future-1', + 'run-terminal', + ]); + + const messages = useMessageStore.getState().messages; + expect(messages.filter((message) => message.content === 'same text')).toHaveLength(2); + expect(messages.filter((message) => message.itemId === 'answer-1')).toHaveLength(1); + expect(messages.find((message) => message.itemId === 'reasoning-1')?.blocks) + .toEqual([expect.objectContaining({ type: 'thinking', content: 'inspect the workspace' })]); + expect(messages.find((message) => message.itemId === 'tool-1')?.blocks) + .toEqual([expect.objectContaining({ + type: 'tool', + toolName: 'read_file', + output: expect.stringContaining('"ok": true'), + })]); + expect(messages.filter((message) => ( + message.itemId === 'tool-1' || message.itemId === 'tool-result-1' + ))).toHaveLength(1); + expect(messages.find((message) => message.itemId === 'a2ui-1')?.aguiActivity) + .toEqual({ surfaceId: 'profile-form', messages: operations }); + // Additive kinds remain in the canonical reducer state for audit/replay, + // but do not add a noisy unsupported-content transcript card. + expect(messages.find((message) => message.itemId === 'future-1')).toBeUndefined(); + expect(messages.some((message) => message.content.includes('