From 9ebffbfbed8dcc63c5594e5754545ac7b60fcbbd Mon Sep 17 00:00:00 2001 From: xiayu Date: Fri, 28 Aug 2026 01:44:16 +0800 Subject: [PATCH 01/24] feat(conversation): publish headless runtime protocol --- README.md | 1 + package.json | 4 + src/__tests__/conversation-protocol.test.ts | 232 ++++++++++++++++++++ src/__tests__/public-exports.test.ts | 12 + src/core/conversation/contracts.ts | 212 ++++++++++++++++++ src/core/conversation/index.ts | 27 +++ src/core/conversation/presentation.ts | 137 ++++++++++++ src/core/conversation/reducer.ts | 116 ++++++++++ src/core/conversation/types.ts | 123 +++++++++++ src/public/conversation.ts | 31 +++ src/public/runtime.ts | 25 +++ src/public/types.ts | 1 + tests/package-contract.test.mjs | 3 + vite.lib.config.ts | 1 + 14 files changed, 925 insertions(+) create mode 100644 src/__tests__/conversation-protocol.test.ts create mode 100644 src/core/conversation/contracts.ts create mode 100644 src/core/conversation/index.ts create mode 100644 src/core/conversation/presentation.ts create mode 100644 src/core/conversation/reducer.ts create mode 100644 src/core/conversation/types.ts create mode 100644 src/public/conversation.ts diff --git a/README.md b/README.md index 57f22ca..3155b4c 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` diff --git a/package.json b/package.json index 277028b..c105a89 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,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" diff --git a/src/__tests__/conversation-protocol.test.ts b/src/__tests__/conversation-protocol.test.ts new file mode 100644 index 0000000..d53fcd3 --- /dev/null +++ b/src/__tests__/conversation-protocol.test.ts @@ -0,0 +1,232 @@ +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); + expect(projectConversationItems(reducer.snapshot()).textItems[0]?.text) + .toBe('hello world'); + }); + + 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('degrades future kinds and 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(presentation.fallbacks).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'future-kind', title: 'Unsupported content' }), + 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__/public-exports.test.ts b/src/__tests__/public-exports.test.ts index 0cc47b0..d97e5e7 100644 --- a/src/__tests__/public-exports.test.ts +++ b/src/__tests__/public-exports.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import * as capabilities from '../public/capabilities.js'; import * as components from '../public/components.js'; +import * as conversation from '../public/conversation.js'; import * as runtime from '../public/runtime.js'; import * as types from '../public/types.js'; @@ -9,6 +10,10 @@ describe('public package entrypoints', () => { expect(typeof runtime.AgentWorkbench).toBe('function'); expect(typeof runtime.ApiFacadeImpl).toBe('function'); expect(typeof runtime.RunEngineImpl).toBe('function'); + expect(typeof runtime.decodeConversationSurface).toBe('function'); + expect(typeof runtime.decodeConversationItem).toBe('function'); + expect(typeof runtime.ConversationItemReducer).toBe('function'); + expect(typeof runtime.projectConversationItems).toBe('function'); expect(runtime.App).toBeUndefined(); }); @@ -20,4 +25,11 @@ describe('public package entrypoints', () => { expect(typeof capabilities.PluginRegistry).toBe('function'); expect(types).toBeDefined(); }); + + it('exports the headless conversation contract as a dedicated entrypoint', () => { + expect(typeof conversation.decodeConversationSurface).toBe('function'); + expect(typeof conversation.decodeConversationItem).toBe('function'); + expect(typeof conversation.ConversationItemReducer).toBe('function'); + expect(typeof conversation.projectConversationItems).toBe('function'); + }); }); diff --git a/src/core/conversation/contracts.ts b/src/core/conversation/contracts.ts new file mode 100644 index 0000000..6e16c24 --- /dev/null +++ b/src/core/conversation/contracts.ts @@ -0,0 +1,212 @@ +import type { + ConversationCapability, + ConversationCapabilityMode, + ConversationItem, + ConversationItemKind, + ConversationItemLifecycle, + ConversationItemOperation, + ConversationItemVisibility, + ConversationSurface, +} from './types.js'; + +const API_VERSION = 'conversation.ksadk.io/v1'; +const CAPABILITY_NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; + +const CAPABILITY_MODES: ReadonlySet = new Set([ + 'native', + 'translated', + 'degraded', + 'unavailable', +]); +const ITEM_KINDS: ReadonlySet = new Set([ + 'user_message', + 'assistant_text', + 'reasoning', + 'tool_call', + 'approval', + 'progress', + 'plan', + 'goal', + 'artifact', + 'a2ui', + 'error', + 'unknown', +]); +const OPERATIONS: ReadonlySet = new Set([ + 'append', + 'replace', + 'completed', +]); +const LIFECYCLES: ReadonlySet = new Set([ + 'pending', + 'streaming', + 'completed', + 'failed', +]); +const VISIBILITIES: ReadonlySet = new Set([ + 'public', + 'internal', + 'hidden', +]); + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function boundedString(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength; +} + +function optionalBoundedString( + value: unknown, + maxLength: number, + allowEmpty = false, +): value is string | null | undefined { + return value === undefined + || value === null + || (typeof value === 'string' + && value.length <= maxLength + && (allowEmpty || value.length > 0)); +} + +function decodeCapability(value: unknown): ConversationCapability | null { + const capability = record(value); + if (!capability + || !boundedString(capability.name, 128) + || !CAPABILITY_NAME.test(capability.name) + || !CAPABILITY_MODES.has(capability.mode as ConversationCapabilityMode) + || !optionalBoundedString(capability.reason, 512, true)) { + return null; + } + if ((capability.mode === 'degraded' || capability.mode === 'unavailable') + && !boundedString(capability.reason, 512)) { + return null; + } + return { + name: capability.name, + mode: capability.mode as ConversationCapabilityMode, + ...(capability.reason === undefined + ? {} + : { reason: capability.reason as string | null }), + }; +} + +function decodeCapabilities(value: unknown): ConversationCapability[] | null { + if (value === undefined) return []; + if (!Array.isArray(value)) return null; + const decoded = value.map(decodeCapability); + if (decoded.some((capability) => capability === null)) return null; + const capabilities = decoded as ConversationCapability[]; + const names = new Set(capabilities.map((capability) => capability.name)); + return names.size === capabilities.length ? capabilities : null; +} + +/** Decode the frozen ConversationSurface/v1 contract without guessing controls. */ +export function decodeConversationSurface(value: unknown): ConversationSurface | null { + const raw = record(value); + if (!raw + || raw.apiVersion !== API_VERSION + || raw.kind !== 'ConversationSurface' + || !boundedString(raw.surfaceId, 256) + || !boundedString(raw.sessionId, 256) + || !boundedString(raw.providerRef, 256)) { + return null; + } + const inputs = decodeCapabilities(raw.inputs); + const outputs = decodeCapabilities(raw.outputs); + if (!inputs || !outputs) return null; + return { + apiVersion: API_VERSION, + kind: 'ConversationSurface', + surfaceId: raw.surfaceId, + sessionId: raw.sessionId, + providerRef: raw.providerRef, + inputs, + outputs, + }; +} + +/** + * Decode ConversationItem/v1. Future item kinds are retained as a passive + * `unknown` card; an unknown contract version or unsafe structural field is + * rejected instead of being guessed. + */ +export function decodeConversationItem(value: unknown): ConversationItem | null { + const raw = record(value); + if (!raw + || raw.apiVersion !== API_VERSION + || raw.kindVersion !== 1 + || !boundedString(raw.itemId, 512) + || !optionalBoundedString(raw.parentItemId, 512) + || !Array.isArray(raw.sourceEventIds) + || raw.sourceEventIds.length === 0 + || raw.sourceEventIds.some((source) => typeof source !== 'string' || source.length === 0) + || new Set(raw.sourceEventIds).size !== raw.sourceEventIds.length + || !boundedString(raw.sessionId, 256) + || !boundedString(raw.runId, 256) + || !boundedString(raw.kind, 128) + || !OPERATIONS.has(raw.operation as ConversationItemOperation) + || !LIFECYCLES.has(raw.lifecycle as ConversationItemLifecycle) + || (raw.visibility !== undefined + && !VISIBILITIES.has(raw.visibility as ConversationItemVisibility)) + || !boundedString(raw.payloadSchemaRef, 256) + || !optionalBoundedString(raw.capabilityRef, 256, true)) { + return null; + } + + const operation = raw.operation as ConversationItemOperation; + const lifecycle = raw.lifecycle as ConversationItemLifecycle; + if (operation === 'completed' + && lifecycle !== 'completed' + && lifecycle !== 'failed') { + return null; + } + + const payload = raw.payload === undefined ? {} : record(raw.payload); + const nativeRef = raw.nativeRef === undefined ? {} : record(raw.nativeRef); + if (!payload || !nativeRef) return null; + + const originalKind = String(raw.kind || 'unknown'); + const kind = ITEM_KINDS.has(originalKind as ConversationItemKind) + ? originalKind as ConversationItemKind + : 'unknown'; + return { + apiVersion: API_VERSION, + kindVersion: 1, + itemId: raw.itemId, + ...(raw.parentItemId === undefined + ? {} + : { parentItemId: raw.parentItemId as string | null }), + sourceEventIds: [...raw.sourceEventIds] as string[], + sessionId: raw.sessionId, + runId: raw.runId, + kind, + operation, + lifecycle, + visibility: (raw.visibility || 'public') as ConversationItemVisibility, + payloadSchemaRef: raw.payloadSchemaRef, + payload: kind === 'unknown' && originalKind !== 'unknown' + ? { + originalKind, + summary: 'This content type is not supported by the current renderer.', + } + : { ...payload }, + ...(raw.capabilityRef === undefined + ? {} + : { capabilityRef: raw.capabilityRef as string | null }), + nativeRef: { ...nativeRef }, + }; +} + +/** Whether this surface allows the browser to submit any of the named inputs. */ +export function surfacePermitsInput( + surface: ConversationSurface, + ...names: string[] +): boolean { + return surface.inputs.some((capability) => ( + names.includes(capability.name) + && (capability.mode === 'native' || capability.mode === 'translated') + )); +} diff --git a/src/core/conversation/index.ts b/src/core/conversation/index.ts new file mode 100644 index 0000000..0356ef5 --- /dev/null +++ b/src/core/conversation/index.ts @@ -0,0 +1,27 @@ +export { + decodeConversationItem, + decodeConversationSurface, + surfacePermitsInput, +} from './contracts.js'; +export { + ConversationItemReducer, + createConversationItemState, + reduceConversationItem, +} from './reducer.js'; +export { projectConversationItems } from './presentation.js'; +export type { + ConversationArtifact, + ConversationCapability, + ConversationCapabilityMode, + ConversationFallbackCard, + ConversationItem, + ConversationItemKind, + ConversationItemLifecycle, + ConversationItemOperation, + ConversationItemReducerState, + ConversationItemVisibility, + ConversationPresentation, + ConversationProjectionOptions, + ConversationSurface, + ConversationTextPresentation, +} from './types.js'; diff --git a/src/core/conversation/presentation.ts b/src/core/conversation/presentation.ts new file mode 100644 index 0000000..ffc20ec --- /dev/null +++ b/src/core/conversation/presentation.ts @@ -0,0 +1,137 @@ +import type { + ConversationArtifact, + ConversationItem, + ConversationItemKind, + ConversationItemReducerState, + ConversationPresentation, + ConversationProjectionOptions, + ConversationTextPresentation, +} from './types.js'; + +const SUPPORTED_SCHEMAS: Partial> = { + user_message: 'conversation.item.user_message/v1', + assistant_text: 'conversation.item.assistant_text/v1', + reasoning: 'conversation.item.reasoning/v1', + tool_call: 'conversation.item.tool-call/v1', + approval: 'conversation.item.approval/v1', + artifact: 'conversation.item.artifact/v1', + a2ui: 'conversation.item.a2ui/v1', + error: 'conversation.item.error/v1', +}; + +function terminal(item: ConversationItem): boolean { + return item.lifecycle === 'completed' || item.lifecycle === 'failed'; +} + +function schemaSupported(item: ConversationItem): boolean { + const expected = SUPPORTED_SCHEMAS[item.kind]; + return expected === undefined || expected === item.payloadSchemaRef; +} + +function safeArtifactUri(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0) return null; + try { + const parsed = new URL(value); + if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + || parsed.username + || parsed.password) { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + +function projectTextItem(item: ConversationItem): ConversationTextPresentation { + return { + id: item.itemId, + parentId: item.parentItemId || null, + runId: item.runId, + kind: item.kind as ConversationTextPresentation['kind'], + text: typeof item.payload.text === 'string' ? item.payload.text : '', + lifecycle: item.lifecycle, + }; +} + +function projectArtifact(item: ConversationItem): ConversationArtifact { + return { + id: item.itemId, + name: typeof item.payload.name === 'string' && item.payload.name + ? item.payload.name + : 'Artifact', + mimeType: typeof item.payload.mimeType === 'string' && item.payload.mimeType + ? item.payload.mimeType + : 'application/octet-stream', + uri: safeArtifactUri(item.payload.uri), + }; +} + +/** + * Produce passive renderer data. Unknown kinds or payload schema versions are + * converted to fallback cards; A2UI and approvals remain typed data and are + * never executed by this projection. + */ +export function projectConversationItems( + state: ConversationItemReducerState, + options: ConversationProjectionOptions = {}, +): ConversationPresentation { + const visible = state.items.filter((item) => ( + item.visibility === 'public' + || (options.includeInternal === true && item.visibility === 'internal') + )); + const supported = visible.filter(schemaSupported); + const unsupported = visible.filter((item) => !schemaSupported(item)); + const textKinds: ReadonlySet = new Set([ + 'user_message', + 'assistant_text', + 'reasoning', + ]); + const textItems = supported + .filter((item) => textKinds.has(item.kind)) + .map(projectTextItem); + const fallbackItems = [ + ...supported.filter((item) => item.kind === 'unknown'), + ...unsupported, + ]; + const failures = supported.filter((item) => item.kind === 'error'); + const terminalItem = [...supported].reverse().find((item) => ( + (item.kind === 'progress' || item.kind === 'error') && terminal(item) + )); + + return { + textItems, + toolItems: supported.filter((item) => item.kind === 'tool_call'), + approvalItems: supported.filter((item) => item.kind === 'approval'), + structuredInputItems: supported.filter((item) => ( + item.kind === 'progress' + && item.payloadSchemaRef === 'conversation.item.structured-input/v1' + )), + a2uiItems: supported.filter((item) => item.kind === 'a2ui'), + artifacts: supported + .filter((item) => item.kind === 'artifact') + .map(projectArtifact), + fallbacks: [ + ...fallbackItems.map((item) => ({ + id: item.itemId, + title: 'Unsupported content', + detail: String( + item.payload.summary + || item.payload.originalKind + || item.payloadSchemaRef, + ), + failed: item.lifecycle === 'failed', + })), + ...failures.map((item) => ({ + id: item.itemId, + title: 'Run failed', + detail: String(item.payload.error || 'The agent run failed.'), + failed: true, + })), + ], + runId: visible.at(-1)?.runId || '', + terminalStatus: terminalItem + ? terminalItem.lifecycle === 'failed' ? 'failed' : 'completed' + : undefined, + }; +} diff --git a/src/core/conversation/reducer.ts b/src/core/conversation/reducer.ts new file mode 100644 index 0000000..acc5207 --- /dev/null +++ b/src/core/conversation/reducer.ts @@ -0,0 +1,116 @@ +import type { + ConversationItem, + ConversationItemReducerState, +} from './types.js'; + +export function createConversationItemState(): ConversationItemReducerState { + return { items: [], appliedSources: [] }; +} + +function terminal(item: ConversationItem): boolean { + return item.lifecycle === 'completed' || item.lifecycle === 'failed'; +} + +function appendPayload( + previous: Record, + incoming: Record, +): Record { + const payload = { ...previous, ...incoming }; + if (typeof previous.text === 'string' && typeof incoming.text === 'string') { + payload.text = previous.text + incoming.text; + } + for (const key of ['data', 'operations'] as const) { + if (Array.isArray(previous[key]) && Array.isArray(incoming[key])) { + payload[key] = [...previous[key], ...incoming[key]]; + } + } + return payload; +} + +/** + * Apply one item operation using wire identity only. + * + * Reconnect replay is idempotent per `(itemId, sourceEventId)`. The reducer + * never compares authors or payload text, so two items with identical content + * remain distinct. Once an item is terminal, a late streaming replay cannot + * make it non-terminal again. + */ +export function reduceConversationItem( + state: ConversationItemReducerState, + incoming: ConversationItem, +): ConversationItemReducerState { + const sourceKeys = incoming.sourceEventIds.map( + // JSON encodes the tuple boundaries, unlike delimiter concatenation where + // a valid identifier containing the delimiter can collide with another + // `(itemId, sourceEventId)` pair. + (source) => JSON.stringify([incoming.itemId, source]), + ); + const seen = new Set(state.appliedSources); + if (sourceKeys.every((key) => seen.has(key))) return state; + sourceKeys.forEach((key) => seen.add(key)); + + const index = state.items.findIndex((item) => item.itemId === incoming.itemId); + const previous = index >= 0 ? state.items[index] : undefined; + if (previous && terminal(previous) && !terminal(incoming)) { + return { ...state, appliedSources: [...seen] }; + } + + const items = [...state.items]; + if (!previous) { + items.push(incoming); + } else { + const sourceEventIds = [ + ...new Set([...previous.sourceEventIds, ...incoming.sourceEventIds]), + ]; + if (previous.kind !== incoming.kind) { + items[index] = { + ...incoming, + kind: 'unknown', + operation: 'replace', + payloadSchemaRef: 'conversation.item.unknown/v1', + payload: { + summary: 'The content type changed for the same item and was safely degraded.', + }, + sourceEventIds, + }; + } else if (incoming.operation === 'append') { + items[index] = { + ...incoming, + payload: appendPayload(previous.payload, incoming.payload), + sourceEventIds, + }; + } else { + items[index] = { ...incoming, sourceEventIds }; + } + } + return { items, appliedSources: [...seen] }; +} + +/** Small stateful facade for applications that do not keep their own store. */ +export class ConversationItemReducer { + private state: ConversationItemReducerState = createConversationItemState(); + + apply(item: ConversationItem): boolean { + const next = reduceConversationItem(this.state, item); + if (next === this.state) return false; + const changed = next.items !== this.state.items; + this.state = next; + return changed; + } + + applyAll(items: Iterable): void { + for (const item of items) this.apply(item); + } + + snapshot(): ConversationItemReducerState { + return { + items: this.state.items.map((item) => ({ + ...item, + sourceEventIds: [...item.sourceEventIds], + payload: { ...item.payload }, + nativeRef: { ...item.nativeRef }, + })), + appliedSources: [...this.state.appliedSources], + }; + } +} diff --git a/src/core/conversation/types.ts b/src/core/conversation/types.ts new file mode 100644 index 0000000..4a96c74 --- /dev/null +++ b/src/core/conversation/types.ts @@ -0,0 +1,123 @@ +/** + * Provider-neutral conversation presentation contracts. + * + * These types mirror the frozen `conversation.ksadk.io/v1` wire contracts. + * They contain no Studio state or provider-specific event names, so Hosted UI + * and custom frontends can share the same decoding and replay semantics. + */ + +export type ConversationCapabilityMode = + | 'native' + | 'translated' + | 'degraded' + | 'unavailable'; + +export type ConversationCapability = { + name: string; + mode: ConversationCapabilityMode; + reason?: string | null; +}; + +export type ConversationSurface = { + apiVersion: 'conversation.ksadk.io/v1'; + kind: 'ConversationSurface'; + surfaceId: string; + sessionId: string; + providerRef: string; + inputs: ConversationCapability[]; + outputs: ConversationCapability[]; +}; + +export type ConversationItemKind = + | 'user_message' + | 'assistant_text' + | 'reasoning' + | 'tool_call' + | 'approval' + | 'progress' + | 'plan' + | 'goal' + | 'artifact' + | 'a2ui' + | 'error' + | 'unknown'; + +export type ConversationItemOperation = 'append' | 'replace' | 'completed'; + +export type ConversationItemLifecycle = + | 'pending' + | 'streaming' + | 'completed' + | 'failed'; + +export type ConversationItemVisibility = 'public' | 'internal' | 'hidden'; + +export type ConversationItem = { + apiVersion: 'conversation.ksadk.io/v1'; + kindVersion: 1; + itemId: string; + parentItemId?: string | null; + sourceEventIds: string[]; + sessionId: string; + runId: string; + kind: ConversationItemKind; + operation: ConversationItemOperation; + lifecycle: ConversationItemLifecycle; + visibility: ConversationItemVisibility; + payloadSchemaRef: string; + payload: Record; + capabilityRef?: string | null; + nativeRef: Record; +}; + +export type ConversationItemReducerState = { + /** Items remain in first-seen order. Equal text never collapses identities. */ + items: ConversationItem[]; + /** Serialized `(itemId, sourceEventId)` pairs used for reconnect replay. */ + appliedSources: string[]; +}; + +export type ConversationTextPresentation = { + id: string; + parentId: string | null; + runId: string; + kind: 'user_message' | 'assistant_text' | 'reasoning'; + text: string; + lifecycle: ConversationItemLifecycle; +}; + +export type ConversationFallbackCard = { + id: string; + title: string; + detail: string; + failed: boolean; +}; + +export type ConversationArtifact = { + id: string; + name: string; + mimeType: string; + /** Only an absolute HTTP(S) URI without embedded credentials is clickable. */ + uri: string | null; +}; + +/** + * Headless, renderer-ready data. This projection never executes item payloads + * and deliberately retains item identities for text and reasoning content. + */ +export type ConversationPresentation = { + textItems: ConversationTextPresentation[]; + toolItems: ConversationItem[]; + approvalItems: ConversationItem[]; + structuredInputItems: ConversationItem[]; + a2uiItems: ConversationItem[]; + artifacts: ConversationArtifact[]; + fallbacks: ConversationFallbackCard[]; + runId: string; + terminalStatus?: 'completed' | 'failed'; +}; + +export type ConversationProjectionOptions = { + /** Internal items are omitted from customer-facing surfaces by default. */ + includeInternal?: boolean; +}; diff --git a/src/public/conversation.ts b/src/public/conversation.ts new file mode 100644 index 0000000..e4864fe --- /dev/null +++ b/src/public/conversation.ts @@ -0,0 +1,31 @@ +/** + * Headless ConversationSurface/ConversationItem v1 entrypoint. + * + * This module is safe to import in Node/SSR environments: it intentionally + * has no React, DOM, transport, or application-shell dependency. + */ +export { + ConversationItemReducer, + createConversationItemState, + decodeConversationItem, + decodeConversationSurface, + projectConversationItems, + reduceConversationItem, + surfacePermitsInput, +} from '../core/conversation/index.js'; +export type { + ConversationArtifact, + ConversationCapability, + ConversationCapabilityMode, + ConversationFallbackCard, + ConversationItem, + ConversationItemKind, + ConversationItemLifecycle, + ConversationItemOperation, + ConversationItemReducerState, + ConversationItemVisibility, + ConversationPresentation, + ConversationProjectionOptions, + ConversationSurface, + ConversationTextPresentation, +} from '../core/conversation/index.js'; diff --git a/src/public/runtime.ts b/src/public/runtime.ts index b7dc8b9..d6205dd 100644 --- a/src/public/runtime.ts +++ b/src/public/runtime.ts @@ -49,3 +49,28 @@ export { A2UI_WIRE_VERSION, validateA2uiPresentation, } from '../core/interaction/index.js'; +export { + ConversationItemReducer, + createConversationItemState, + decodeConversationItem, + decodeConversationSurface, + projectConversationItems, + reduceConversationItem, + surfacePermitsInput, +} from '../core/conversation/index.js'; +export type { + ConversationArtifact, + ConversationCapability, + ConversationCapabilityMode, + ConversationFallbackCard, + ConversationItem, + ConversationItemKind, + ConversationItemLifecycle, + ConversationItemOperation, + ConversationItemReducerState, + ConversationItemVisibility, + ConversationPresentation, + ConversationProjectionOptions, + ConversationSurface, + ConversationTextPresentation, +} from '../core/conversation/index.js'; diff --git a/src/public/types.ts b/src/public/types.ts index b40e393..3295262 100644 --- a/src/public/types.ts +++ b/src/public/types.ts @@ -3,3 +3,4 @@ export type * from '../types/bootstrap.js'; export type * from '../types/capabilities.js'; export type * from '../types/input.js'; export type * from '../types/session-events.js'; +export type * from '../core/conversation/types.js'; diff --git a/tests/package-contract.test.mjs b/tests/package-contract.test.mjs index cb4e6f3..d1a0e81 100644 --- a/tests/package-contract.test.mjs +++ b/tests/package-contract.test.mjs @@ -16,6 +16,7 @@ test('package metadata exposes release artifacts and public entrypoints', () => '.', './capabilities', './components', + './conversation', './runtime', './styles', './types', @@ -23,6 +24,8 @@ test('package metadata exposes release artifacts and public entrypoints', () => assert.equal(packageJson.exports['./runtime'].types, './dist-lib/public/runtime.d.ts'); assert.equal(packageJson.exports['./runtime'].import, './dist-lib/runtime.js'); assert.equal(packageJson.exports['./components'].types, './dist-lib/public/components.d.ts'); + assert.equal(packageJson.exports['./conversation'].types, './dist-lib/public/conversation.d.ts'); + assert.equal(packageJson.exports['./conversation'].import, './dist-lib/conversation.js'); assert.equal(packageJson.exports['./capabilities'].types, './dist-lib/public/capabilities.d.ts'); assert.equal(packageJson.exports['./types'].types, './dist-lib/public/types.d.ts'); assert.equal(packageJson.exports['./styles'], './dist-lib/styles.css'); diff --git a/vite.lib.config.ts b/vite.lib.config.ts index 849890f..08d7591 100644 --- a/vite.lib.config.ts +++ b/vite.lib.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ entry: { capabilities: path.resolve(__dirname, "src/public/capabilities.ts"), components: path.resolve(__dirname, "src/public/components.ts"), + conversation: path.resolve(__dirname, "src/public/conversation.ts"), runtime: path.resolve(__dirname, "src/public/runtime.ts"), styles: path.resolve(__dirname, "src/public/styles.ts"), types: path.resolve(__dirname, "src/public/types.ts"), From 71ca1ff46552d4a570bfcf1e5b04d4692a69b509 Mon Sep 17 00:00:00 2001 From: xiayu Date: Fri, 28 Aug 2026 02:10:53 +0800 Subject: [PATCH 02/24] feat(conversation): add headless streaming client --- README.md | 31 ++ src/__tests__/conversation-client.test.ts | 292 ++++++++++++++ src/__tests__/public-exports.test.ts | 4 + src/core/conversation/client.ts | 441 ++++++++++++++++++++++ src/core/conversation/contracts.ts | 172 +++++++++ src/core/conversation/errors.ts | 34 ++ src/core/conversation/index.ts | 18 + src/core/conversation/types.ts | 93 +++++ src/public/conversation.ts | 21 +- 9 files changed, 1105 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/conversation-client.test.ts create mode 100644 src/core/conversation/client.ts create mode 100644 src/core/conversation/errors.ts diff --git a/README.md b/README.md index 3155b4c..52a2d7e 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,37 @@ 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' }], + }), +}) +``` + +The client submits a turn once and only reconnects through the canonical Run +event endpoint. 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. + ## Release Contract Consumers should record the resolved KSADK Web package version and lockfile diff --git a/src/__tests__/conversation-client.test.ts b/src/__tests__/conversation-client.test.ts new file mode 100644 index 0000000..9c4b265 --- /dev/null +++ b/src/__tests__/conversation-client.test.ts @@ -0,0 +1,292 @@ +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', + approvalMode: 'risk', + collaborationMode: 'plan', + goalObjective: 'finish the task', + extensions: { '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__/public-exports.test.ts b/src/__tests__/public-exports.test.ts index d97e5e7..21d1245 100644 --- a/src/__tests__/public-exports.test.ts +++ b/src/__tests__/public-exports.test.ts @@ -27,9 +27,13 @@ describe('public package entrypoints', () => { }); it('exports the headless conversation contract as a dedicated entrypoint', () => { + expect(typeof conversation.buildConversationInput).toBe('function'); + expect(typeof conversation.decodeConversationInput).toBe('function'); expect(typeof conversation.decodeConversationSurface).toBe('function'); expect(typeof conversation.decodeConversationItem).toBe('function'); expect(typeof conversation.ConversationItemReducer).toBe('function'); + expect(typeof conversation.HttpConversationClient).toBe('function'); + expect(typeof conversation.ConversationClientError).toBe('function'); expect(typeof conversation.projectConversationItems).toBe('function'); }); }); diff --git a/src/core/conversation/client.ts b/src/core/conversation/client.ts new file mode 100644 index 0000000..cc7933f --- /dev/null +++ b/src/core/conversation/client.ts @@ -0,0 +1,441 @@ +import { + decodeConversationItem, + decodeConversationSurface, + preflightConversationInput, +} from './contracts.js'; +import { ConversationClientError } from './errors.js'; +import { projectConversationItems } from './presentation.js'; +import { ConversationItemReducer } from './reducer.js'; +import type { + ConversationClientOptions, + ConversationFetch, + ConversationItem, + ConversationStreamResult, + ConversationStreamTurnOptions, + ConversationSurfaceBootstrap, +} from './types.js'; + +const DEFAULT_MAX_RECONNECTS = 8; + +function object(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function aborted(signal?: AbortSignal): never | void { + if (signal?.aborted) { + throw new ConversationClientError( + 'conversation_aborted', + 'Conversation request was aborted.', + ); + } +} + +function isAbortFailure(error: unknown, signal?: AbortSignal): boolean { + return signal?.aborted === true + || (error instanceof Error && error.name === 'AbortError'); +} + +function normalizeBaseUrl(value: string): string { + const baseUrl = value.trim().replace(/\/+$/, ''); + if (!baseUrl) return ''; + if (!baseUrl.includes('://')) return baseUrl.startsWith('/') ? baseUrl : `/${baseUrl}`; + try { + const parsed = new URL(baseUrl); + if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + || parsed.username + || parsed.password) { + throw new Error('unsafe URL'); + } + return parsed.toString().replace(/\/$/, ''); + } catch (cause) { + throw new ConversationClientError( + 'conversation_contract_mismatch', + 'Conversation base URL must be an HTTP(S) URL without embedded credentials.', + { cause }, + ); + } +} + +function defaultRetryDelay(attempt: number): number { + return Math.min(200 * (2 ** Math.max(0, attempt - 1)), 2_000); +} + +function defaultSleep(delayMilliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, delayMilliseconds); + }); +} + +async function waitForRetry( + sleep: (delayMilliseconds: number) => Promise, + delayMilliseconds: number, + signal?: AbortSignal, +): Promise { + aborted(signal); + if (!signal) { + await sleep(delayMilliseconds); + return; + } + await new Promise((resolve, reject) => { + const onAbort = () => reject(new ConversationClientError( + 'conversation_aborted', + 'Conversation request was aborted.', + )); + signal.addEventListener('abort', onAbort, { once: true }); + void sleep(delayMilliseconds).then(resolve, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} + +function createResult( + reducer: ConversationItemReducer, + cursor: number, + runId: string, +): ConversationStreamResult { + const state = reducer.snapshot(); + return { + cursor, + runId, + state, + presentation: projectConversationItems(state), + }; +} + +type StreamContext = { + cursor: number; + runId: string; + reducer: ConversationItemReducer; + options: ConversationStreamTurnOptions; +}; + +function processFrame(frame: string, context: StreamContext): void { + let id: number | undefined; + const dataLines: string[] = []; + for (const rawLine of frame.split(/\r?\n/)) { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + if (!line || line.startsWith(':')) continue; + const separator = line.indexOf(':'); + const field = separator < 0 ? line : line.slice(0, separator); + const rawValue = separator < 0 ? '' : line.slice(separator + 1); + const value = rawValue.startsWith(' ') ? rawValue.slice(1) : rawValue; + if (field === 'id') { + if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) { + throw new ConversationClientError( + 'conversation_stream_error', + 'Conversation stream contains an invalid replay cursor.', + ); + } + id = Number(value); + } else if (field === 'data') { + dataLines.push(value); + } + } + if (dataLines.length === 0) return; + const data = dataLines.join('\n'); + if (!data || data === '[DONE]') return; + + let payload: unknown; + try { + payload = JSON.parse(data) as unknown; + } catch (cause) { + throw new ConversationClientError( + 'conversation_contract_mismatch', + 'Conversation stream contains invalid JSON.', + { cause }, + ); + } + const raw = object(payload); + if (!raw || raw.conversationItem === undefined) { + if (id !== undefined) context.cursor = Math.max(context.cursor, id); + return; + } + if (id === undefined) { + throw new ConversationClientError( + 'conversation_stream_error', + 'Canonical conversation items require an SSE replay cursor.', + ); + } + const item = decodeConversationItem(raw.conversationItem); + if (!item) { + throw new ConversationClientError( + 'conversation_contract_mismatch', + 'Conversation stream item does not match conversation.ksadk.io/v1.', + ); + } + if (context.runId && context.runId !== item.runId) { + throw new ConversationClientError( + 'conversation_contract_mismatch', + 'Conversation stream changed canonical run identity.', + { runId: context.runId, cursor: context.cursor }, + ); + } + context.runId = item.runId; + const changed = context.reducer.apply(item); + context.cursor = Math.max(context.cursor, id); + if (changed) context.options.onItem?.(item); + context.options.onUpdate?.(createResult( + context.reducer, + context.cursor, + context.runId, + )); +} + +async function consumeEventStream( + response: Response, + context: StreamContext, +): Promise { + if (!response.body) { + throw new ConversationClientError( + 'conversation_stream_error', + 'Conversation response has no event stream body.', + ); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (true) { + aborted(context.options.signal); + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let boundary = /\r?\n\r?\n/.exec(buffer); + while (boundary?.index !== undefined) { + const end = boundary.index; + const length = boundary[0].length; + processFrame(buffer.slice(0, end), context); + buffer = buffer.slice(end + length); + boundary = /\r?\n\r?\n/.exec(buffer); + } + } + buffer += decoder.decode(); + if (buffer.trim()) processFrame(buffer, context); + } catch (error) { + if (error instanceof ConversationClientError) throw error; + if (isAbortFailure(error, context.options.signal)) aborted(context.options.signal); + throw new ConversationClientError( + 'conversation_stream_error', + 'Conversation event stream was interrupted.', + { cause: error, runId: context.runId || undefined, cursor: context.cursor }, + ); + } finally { + reader.releaseLock(); + } +} + +function terminal(result: ConversationStreamResult): boolean { + return result.state.items.some((item) => ( + (item.kind === 'progress' || item.kind === 'error') + && (item.lifecycle === 'completed' || item.lifecycle === 'failed') + )); +} + +/** + * Minimal HTTP/SSE reference client for ConversationSurface/Input/Item v1. + * + * It never accepts auth headers or credentials. Applications are expected to + * inject a same-origin fetch implementation or an already-authenticated server + * transport outside this protocol-neutral package. + */ +export class HttpConversationClient { + private readonly fetcher?: ConversationFetch; + + private readonly baseUrl: string; + + private readonly maxReconnects: number; + + private readonly sleep: (delayMilliseconds: number) => Promise; + + private readonly retryDelayMs: (attempt: number) => number; + + constructor(options: ConversationClientOptions = {}) { + if (!Number.isInteger(options.maxReconnects ?? DEFAULT_MAX_RECONNECTS) + || (options.maxReconnects ?? DEFAULT_MAX_RECONNECTS) < 0 + || (options.maxReconnects ?? DEFAULT_MAX_RECONNECTS) > 32) { + throw new ConversationClientError( + 'conversation_contract_mismatch', + 'Conversation reconnect limit must be an integer from 0 to 32.', + ); + } + this.fetcher = options.fetch; + this.baseUrl = normalizeBaseUrl(options.baseUrl || ''); + this.maxReconnects = options.maxReconnects ?? DEFAULT_MAX_RECONNECTS; + this.sleep = options.sleep || defaultSleep; + this.retryDelayMs = options.retryDelayMs || defaultRetryDelay; + } + + private fetch(): ConversationFetch { + if (this.fetcher) return this.fetcher; + if (typeof globalThis.fetch === 'function') { + return globalThis.fetch.bind(globalThis) as ConversationFetch; + } + throw new ConversationClientError( + 'conversation_http_error', + 'No fetch implementation is available for the conversation client.', + ); + } + + private url(path: string): string { + return `${this.baseUrl}${path}`; + } + + private async request( + url: string, + init: RequestInit | undefined, + signal?: AbortSignal, + errorCode: 'conversation_http_error' | 'conversation_stream_error' = 'conversation_stream_error', + ): Promise { + aborted(signal); + try { + return await this.fetch()(url, init); + } catch (error) { + if (error instanceof ConversationClientError) throw error; + if (isAbortFailure(error, signal)) aborted(signal); + throw new ConversationClientError( + errorCode, + 'Conversation network request failed.', + { cause: error }, + ); + } + } + + private assertOk(response: Response): void { + if (!response.ok) { + throw new ConversationClientError( + 'conversation_http_error', + `Conversation endpoint returned HTTP ${response.status}.`, + { status: response.status }, + ); + } + } + + async getSurface( + agentId: string, + sessionId: string, + options: { signal?: AbortSignal } = {}, + ): Promise { + if (!agentId || !sessionId) { + throw new ConversationClientError( + 'conversation_contract_mismatch', + 'Agent and session identities are required to get a conversation surface.', + ); + } + const init = options.signal ? { signal: options.signal } : undefined; + const response = await this.request(this.url( + `/api/v1/agents/${encodeURIComponent(agentId)}/conversation-surface` + + `?sessionId=${encodeURIComponent(sessionId)}`, + ), init, options.signal, 'conversation_http_error'); + this.assertOk(response); + let payload: unknown; + try { + payload = await response.json() as unknown; + } catch (cause) { + throw new ConversationClientError( + 'conversation_contract_mismatch', + 'Conversation surface response is not valid JSON.', + { cause }, + ); + } + const raw = object(payload); + const surface = decodeConversationSurface(raw?.surface); + if (!raw || typeof raw.buildId !== 'string' || !raw.buildId || !surface) { + throw new ConversationClientError( + 'conversation_contract_mismatch', + 'Conversation surface response does not match conversation.ksadk.io/v1.', + ); + } + return { buildId: raw.buildId, surface }; + } + + async streamTurn(options: ConversationStreamTurnOptions): Promise { + aborted(options.signal); + preflightConversationInput(options.bootstrap.surface, options.input); + const context: StreamContext = { + cursor: 0, + runId: '', + reducer: new ConversationItemReducer(), + options, + }; + const postInit: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': options.input.idempotencyKey, + }, + body: JSON.stringify({ input: options.input }), + ...(options.signal ? { signal: options.signal } : {}), + }; + const initial = await this.request(this.url( + `/api/v1/builds/${encodeURIComponent(options.bootstrap.buildId)}/conversation:stream`, + ), postInit, options.signal); + this.assertOk(initial); + try { + await consumeEventStream(initial, context); + } catch (error) { + if (!(error instanceof ConversationClientError) + || error.code !== 'conversation_stream_error' + || !context.runId) { + throw error; + } + } + + let result = createResult(context.reducer, context.cursor, context.runId); + if (terminal(result)) return result; + if (!context.runId) { + throw new ConversationClientError( + 'conversation_run_identity_missing', + 'The stream ended before a canonical item supplied its run identity.', + { cursor: context.cursor }, + ); + } + + for (let attempt = 1; attempt <= this.maxReconnects; attempt += 1) { + await waitForRetry( + this.sleep, + this.retryDelayMs(attempt), + options.signal, + ); + aborted(options.signal); + let replay: Response; + try { + replay = await this.request(this.url( + `/api/v1/runs/${encodeURIComponent(context.runId)}/events?after=${context.cursor}`, + ), { + method: 'GET', + headers: { 'Last-Event-ID': String(context.cursor) }, + ...(options.signal ? { signal: options.signal } : {}), + }, options.signal); + this.assertOk(replay); + await consumeEventStream(replay, context); + } catch (error) { + if (error instanceof ConversationClientError + && error.code === 'conversation_aborted') { + throw error; + } + if (error instanceof ConversationClientError + && error.code === 'conversation_contract_mismatch') { + throw error; + } + if (error instanceof ConversationClientError + && error.code === 'conversation_http_error' + && error.status !== undefined + && error.status < 500) { + throw error; + } + continue; + } + result = createResult(context.reducer, context.cursor, context.runId); + if (terminal(result)) return result; + } + throw new ConversationClientError( + 'conversation_reconnect_exhausted', + 'Conversation replay stopped after the configured reconnect limit.', + { runId: context.runId, cursor: context.cursor }, + ); + } +} + +export type { ConversationItem }; diff --git a/src/core/conversation/contracts.ts b/src/core/conversation/contracts.ts index 6e16c24..e529bb8 100644 --- a/src/core/conversation/contracts.ts +++ b/src/core/conversation/contracts.ts @@ -7,7 +7,11 @@ import type { ConversationItemOperation, ConversationItemVisibility, ConversationSurface, + ConversationInput, + ConversationInputDraft, + ConversationInputPart, } from './types.js'; +import { ConversationClientError } from './errors.js'; const API_VERSION = 'conversation.ksadk.io/v1'; const CAPABILITY_NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; @@ -48,6 +52,29 @@ const VISIBILITIES: ReadonlySet = new Set([ 'internal', 'hidden', ]); +const INPUT_KEYS = new Set([ + 'apiVersion', + 'kind', + 'inputId', + 'sessionId', + 'idempotencyKey', + 'parts', + 'modelRef', + 'reasoning', + 'approvalMode', + 'collaborationMode', + 'goalObjective', + 'extensions', +]); +const TEXT_PART_KEYS = new Set(['kind', 'text']); +const ATTACHMENT_PART_KEYS = new Set([ + 'kind', + 'attachmentRef', + 'mediaType', + 'name', +]); +const APPROVAL_MODES = new Set(['ask', 'risk', 'full']); +const COLLABORATION_MODES = new Set(['default', 'plan']); function record(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) @@ -71,6 +98,151 @@ function optionalBoundedString( && (allowEmpty || value.length > 0)); } +function hasOnlyKeys(value: Record, allowed: Set): boolean { + return Object.keys(value).every((key) => allowed.has(key)); +} + +function decodeInputPart(value: unknown): ConversationInputPart | null { + const part = record(value); + if (!part || typeof part.kind !== 'string') return null; + if (part.kind === 'text') { + if (!hasOnlyKeys(part, TEXT_PART_KEYS) || !boundedString(part.text, 131_072)) { + return null; + } + return { kind: 'text', text: part.text }; + } + if (part.kind === 'attachment') { + if (!hasOnlyKeys(part, ATTACHMENT_PART_KEYS) + || !boundedString(part.attachmentRef, 2_048) + || !boundedString(part.mediaType, 256) + || !optionalBoundedString(part.name, 1_024, true)) { + return null; + } + return { + kind: 'attachment', + attachmentRef: part.attachmentRef, + mediaType: part.mediaType, + ...(part.name === undefined ? {} : { name: part.name as string | null }), + }; + } + return null; +} + +function decodeExtensions(value: unknown): Record | null { + if (value === undefined) return {}; + const extensions = record(value); + if (!extensions || Object.keys(extensions).some((key) => ( + !CAPABILITY_NAME.test(key) || !key.includes('.') + ))) { + return null; + } + return { ...extensions }; +} + +/** Decode the strict, provider-neutral ConversationInput/v1 contract. */ +export function decodeConversationInput(value: unknown): ConversationInput | null { + const raw = record(value); + if (!raw + || !hasOnlyKeys(raw, INPUT_KEYS) + || raw.apiVersion !== API_VERSION + || raw.kind !== 'ConversationInput' + || !boundedString(raw.inputId, 256) + || !boundedString(raw.sessionId, 256) + || !boundedString(raw.idempotencyKey, 512) + || !Array.isArray(raw.parts) + || raw.parts.length === 0 + || !optionalBoundedString(raw.modelRef, 256) + || !optionalBoundedString(raw.reasoning, 64) + || (raw.approvalMode !== undefined + && raw.approvalMode !== null + && !APPROVAL_MODES.has(String(raw.approvalMode))) + || (raw.collaborationMode !== undefined + && raw.collaborationMode !== null + && !COLLABORATION_MODES.has(String(raw.collaborationMode))) + || !optionalBoundedString(raw.goalObjective, 4_096)) { + return null; + } + const parts = raw.parts.map(decodeInputPart); + const extensions = decodeExtensions(raw.extensions); + if (parts.some((part) => part === null) || extensions === null) return null; + return { + apiVersion: API_VERSION, + kind: 'ConversationInput', + inputId: raw.inputId, + sessionId: raw.sessionId, + idempotencyKey: raw.idempotencyKey, + parts: parts as ConversationInputPart[], + ...(raw.modelRef === undefined ? {} : { modelRef: raw.modelRef as string | null }), + ...(raw.reasoning === undefined ? {} : { reasoning: raw.reasoning as string | null }), + ...(raw.approvalMode === undefined + ? {} + : { approvalMode: raw.approvalMode as ConversationInput['approvalMode'] }), + ...(raw.collaborationMode === undefined + ? {} + : { collaborationMode: raw.collaborationMode as ConversationInput['collaborationMode'] }), + ...(raw.goalObjective === undefined + ? {} + : { goalObjective: raw.goalObjective as string | null }), + ...(raw.extensions === undefined ? {} : { extensions }), + }; +} + +/** Build only the frozen contract fields; callers must supply all identities. */ +export function buildConversationInput(draft: ConversationInputDraft): ConversationInput { + const decoded = decodeConversationInput({ + ...draft, + apiVersion: draft.apiVersion || API_VERSION, + kind: draft.kind || 'ConversationInput', + }); + if (!decoded) { + throw new ConversationClientError( + 'conversation_contract_mismatch', + 'Conversation input does not match conversation.ksadk.io/v1.', + ); + } + return decoded; +} + +function requiredInputCapabilities(input: ConversationInput): string[] { + const capabilities: string[] = input.parts.map((part) => ( + part.kind === 'text' + ? 'text' + : part.mediaType.toLowerCase().startsWith('image/') + ? 'attachment.image' + : 'attachment.file' + )); + if (input.modelRef) capabilities.push('model.select'); + if (input.reasoning) capabilities.push('reasoning.effort'); + if (input.approvalMode) capabilities.push('approval'); + if (input.collaborationMode === 'plan') capabilities.push('plan'); + if (input.goalObjective) capabilities.push('goal'); + capabilities.push(...Object.keys(input.extensions || {})); + return [...new Set(capabilities)]; +} + +/** Enforce the active Surface before any network request is created. */ +export function preflightConversationInput( + surface: ConversationSurface, + input: ConversationInput, +): ConversationInput { + if (surface.sessionId !== input.sessionId) { + throw new ConversationClientError( + 'conversation_session_mismatch', + 'Conversation input session does not match the active surface.', + ); + } + for (const capability of requiredInputCapabilities(input)) { + if (!surfacePermitsInput(surface, capability)) { + throw new ConversationClientError( + 'conversation_input_unsupported', + 'Conversation input is not declared by the active surface.', + { capability }, + ); + } + } + return input; +} + function decodeCapability(value: unknown): ConversationCapability | null { const capability = record(value); if (!capability diff --git a/src/core/conversation/errors.ts b/src/core/conversation/errors.ts new file mode 100644 index 0000000..3773b68 --- /dev/null +++ b/src/core/conversation/errors.ts @@ -0,0 +1,34 @@ +import type { + ConversationClientErrorCode, + ConversationClientErrorDetails, +} from './types.js'; + +/** Stable, non-secret-bearing failures for headless conversation clients. */ +export class ConversationClientError extends Error { + readonly code: ConversationClientErrorCode; + + readonly status?: number; + + readonly capability?: string; + + readonly runId?: string; + + readonly cursor?: number; + + override readonly cause?: unknown; + + constructor( + code: ConversationClientErrorCode, + message: string, + details: ConversationClientErrorDetails = {}, + ) { + super(message); + this.name = 'ConversationClientError'; + this.code = code; + this.status = details.status; + this.capability = details.capability; + this.runId = details.runId; + this.cursor = details.cursor; + this.cause = details.cause; + } +} diff --git a/src/core/conversation/index.ts b/src/core/conversation/index.ts index 0356ef5..b46a094 100644 --- a/src/core/conversation/index.ts +++ b/src/core/conversation/index.ts @@ -1,8 +1,13 @@ export { + buildConversationInput, decodeConversationItem, + decodeConversationInput, decodeConversationSurface, + preflightConversationInput, surfacePermitsInput, } from './contracts.js'; +export { HttpConversationClient } from './client.js'; +export { ConversationClientError } from './errors.js'; export { ConversationItemReducer, createConversationItemState, @@ -13,7 +18,15 @@ export type { ConversationArtifact, ConversationCapability, ConversationCapabilityMode, + ConversationClientErrorCode, + ConversationClientErrorDetails, + ConversationClientOptions, + ConversationFetch, ConversationFallbackCard, + ConversationInput, + ConversationInputDraft, + ConversationInputPart, + ConversationAttachmentPart, ConversationItem, ConversationItemKind, ConversationItemLifecycle, @@ -23,5 +36,10 @@ export type { ConversationPresentation, ConversationProjectionOptions, ConversationSurface, + ConversationSurfaceBootstrap, + ConversationStreamObserver, + ConversationStreamResult, + ConversationStreamTurnOptions, + ConversationTextPart, ConversationTextPresentation, } from './types.js'; diff --git a/src/core/conversation/types.ts b/src/core/conversation/types.ts index 4a96c74..4c74916 100644 --- a/src/core/conversation/types.ts +++ b/src/core/conversation/types.ts @@ -28,6 +28,50 @@ export type ConversationSurface = { outputs: ConversationCapability[]; }; +export type ConversationTextPart = { + kind: 'text'; + text: string; +}; + +export type ConversationAttachmentPart = { + kind: 'attachment'; + attachmentRef: string; + mediaType: string; + name?: string | null; +}; + +export type ConversationInputPart = + | ConversationTextPart + | ConversationAttachmentPart; + +export type ConversationInput = { + apiVersion: 'conversation.ksadk.io/v1'; + kind: 'ConversationInput'; + inputId: string; + sessionId: string; + idempotencyKey: string; + parts: ConversationInputPart[]; + modelRef?: string | null; + reasoning?: string | null; + approvalMode?: 'ask' | 'risk' | 'full' | null; + collaborationMode?: 'default' | 'plan' | null; + goalObjective?: string | null; + extensions?: Record; +}; + +export type ConversationInputDraft = Omit< + ConversationInput, + 'apiVersion' | 'kind' +> & { + apiVersion?: 'conversation.ksadk.io/v1'; + kind?: 'ConversationInput'; +}; + +export type ConversationSurfaceBootstrap = { + buildId: string; + surface: ConversationSurface; +}; + export type ConversationItemKind = | 'user_message' | 'assistant_text' @@ -121,3 +165,52 @@ export type ConversationProjectionOptions = { /** Internal items are omitted from customer-facing surfaces by default. */ includeInternal?: boolean; }; + +export type ConversationClientErrorCode = + | 'conversation_contract_mismatch' + | 'conversation_input_unsupported' + | 'conversation_session_mismatch' + | 'conversation_http_error' + | 'conversation_stream_error' + | 'conversation_run_identity_missing' + | 'conversation_reconnect_exhausted' + | 'conversation_aborted'; + +export type ConversationClientErrorDetails = { + status?: number; + capability?: string; + runId?: string; + cursor?: number; + cause?: unknown; +}; + +export type ConversationStreamResult = { + cursor: number; + runId: string; + state: ConversationItemReducerState; + presentation: ConversationPresentation; +}; + +export type ConversationStreamObserver = { + onItem?: (item: ConversationItem) => void; + onUpdate?: (result: ConversationStreamResult) => void; +}; + +export type ConversationFetch = ( + url: string, + init?: RequestInit, +) => Promise; + +export type ConversationClientOptions = { + fetch?: ConversationFetch; + baseUrl?: string; + maxReconnects?: number; + sleep?: (delayMilliseconds: number) => Promise; + retryDelayMs?: (attempt: number) => number; +}; + +export type ConversationStreamTurnOptions = ConversationStreamObserver & { + bootstrap: ConversationSurfaceBootstrap; + input: ConversationInput; + signal?: AbortSignal; +}; diff --git a/src/public/conversation.ts b/src/public/conversation.ts index e4864fe..d350f2c 100644 --- a/src/public/conversation.ts +++ b/src/public/conversation.ts @@ -2,13 +2,19 @@ * Headless ConversationSurface/ConversationItem v1 entrypoint. * * This module is safe to import in Node/SSR environments: it intentionally - * has no React, DOM, transport, or application-shell dependency. + * has no React, DOM, or application-shell dependency. Its optional reference + * transport uses only injected/global Fetch, Web Streams, and TextDecoder. */ export { ConversationItemReducer, + ConversationClientError, + HttpConversationClient, + buildConversationInput, createConversationItemState, decodeConversationItem, + decodeConversationInput, decodeConversationSurface, + preflightConversationInput, projectConversationItems, reduceConversationItem, surfacePermitsInput, @@ -17,7 +23,15 @@ export type { ConversationArtifact, ConversationCapability, ConversationCapabilityMode, + ConversationClientErrorCode, + ConversationClientErrorDetails, + ConversationClientOptions, + ConversationFetch, ConversationFallbackCard, + ConversationInput, + ConversationInputDraft, + ConversationInputPart, + ConversationAttachmentPart, ConversationItem, ConversationItemKind, ConversationItemLifecycle, @@ -27,5 +41,10 @@ export type { ConversationPresentation, ConversationProjectionOptions, ConversationSurface, + ConversationSurfaceBootstrap, + ConversationStreamObserver, + ConversationStreamResult, + ConversationStreamTurnOptions, + ConversationTextPart, ConversationTextPresentation, } from '../core/conversation/index.js'; From 1b51ae3649339f6c30d1f6aa533a7ab9f2b3f8c1 Mon Sep 17 00:00:00 2001 From: xiayu Date: Fri, 28 Aug 2026 03:08:42 +0800 Subject: [PATCH 03/24] feat(conversation): make hosted UI surface first --- README.md | 6 + src/__tests__/hosted-conversation.test.ts | 266 ++++++++++++++++ src/__tests__/run-engine.test.ts | 243 +++++++++++++++ src/core/conversation/hosted.ts | 364 ++++++++++++++++++++++ src/core/conversation/types.ts | 10 + src/core/run/dispatcher.ts | 18 ++ src/core/run/engine.ts | 214 ++++++++++++- src/core/run/types.ts | 7 + src/hooks/useRunAgent.ts | 6 + src/public/conversation.ts | 1 + 10 files changed, 1129 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/hosted-conversation.test.ts create mode 100644 src/core/conversation/hosted.ts diff --git a/README.md b/README.md index 52a2d7e..cc8633f 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,12 @@ event endpoint. 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. + ## Release Contract Consumers should record the resolved KSADK Web package version and lockfile diff --git a/src/__tests__/hosted-conversation.test.ts b/src/__tests__/hosted-conversation.test.ts new file mode 100644 index 0000000..c651ed6 --- /dev/null +++ b/src/__tests__/hosted-conversation.test.ts @@ -0,0 +1,266 @@ +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, A2UI and fallback', 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' }, + output: { ok: true }, + }, + )), + frame(5, item( + 'approval-item-1', + 'event-5', + 'approval', + 'conversation.item.approval/v1', + { + interactionId: 'approval-1', + revision: 2, + kind: 'command', + prompt: 'Allow command?', + detail: { command: 'echo safe' }, + }, + { lifecycle: 'pending' }, + )), + frame(6, item( + 'a2ui-1', + 'event-6', + 'a2ui', + 'conversation.item.a2ui/v1', + { data: operations }, + )), + frame(7, item( + 'future-1', + 'event-7', + 'game_board', + 'vendor.game-board/v7', + { html: '' }, + )), + frame(8, item( + 'run-terminal', + 'event-8', + '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', + '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' })]); + expect(messages.find((message) => message.itemId === 'a2ui-1')?.aguiActivity) + .toEqual({ surfaceId: 'profile-form', messages: operations }); + expect(messages.find((message) => message.itemId === 'future-1')).toMatchObject({ + role: 'system', + content: expect.stringContaining('Unsupported content'), + }); + expect(messages.some((message) => message.content.includes('', { 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([]); +}); diff --git a/e2e/fixtures/canonical-conversation-server.mjs b/e2e/fixtures/canonical-conversation-server.mjs new file mode 100644 index 0000000..a219bcb --- /dev/null +++ b/e2e/fixtures/canonical-conversation-server.mjs @@ -0,0 +1,504 @@ +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 { + inputs: [], + 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)); +} + +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 chunks = []; + for await (const chunk of request) chunks.push(chunk); + if (chunks.length === 0) return {}; + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +function bootstrap() { + return { + Agent: { AgentId: AGENT_ID, Name: 'Canonical Fixture', Framework: 'codex' }, + ApiFormats: ['responses'], + Capabilities: { + Attachments: false, + 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' }, + ], + 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(); + 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' }] }, + 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/state') { + sendJson(response, { + inputs: state.inputs, + 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/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.json b/package.json index c105a89..1903733 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test": "vitest run src", "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" 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/src/components/chat/A2UIActivityMessage.tsx b/src/components/chat/A2UIActivityMessage.tsx index 6c19639..7ce8cb1 100644 --- a/src/components/chat/A2UIActivityMessage.tsx +++ b/src/components/chat/A2UIActivityMessage.tsx @@ -9,16 +9,16 @@ import { ksadkA2uiCatalog } from '../../core/run/a2ui.js'; function ActivitySurface({ surfaceId, - messages, + serializedMessages, }: { surfaceId: string; - messages: Array>; + serializedMessages: string; }) { const { processMessages } = useA2UI(); useEffect(() => { - processMessages(messages); - }, [messages, processMessages]); + processMessages(JSON.parse(serializedMessages) as Array>); + }, [serializedMessages, processMessages]); return ( >; onAction?: (message: A2UIClientEventMessage) => void; }) { + // Canonical snapshots contain the full reduced A2UI item on every update. + // Reprocessing an unchanged createSurface batch makes the renderer reject + // the replay as "Surface already exists". Key the provider by the passive + // operation batch: identical snapshots keep their state without rerunning, + // while an append/replace batch starts from a clean catalog state and safely + // replays the new full snapshot. + const serializedMessages = JSON.stringify(messages); return (
- - + +
); diff --git a/vite.canonical-conversation.config.mjs b/vite.canonical-conversation.config.mjs new file mode 100644 index 0000000..115ef67 --- /dev/null +++ b/vite.canonical-conversation.config.mjs @@ -0,0 +1,20 @@ +import { mergeConfig } from 'vite'; + +import baseConfig from './vite.config.ts'; + +const fixtureTarget = 'http://127.0.0.1:4182'; + +export default mergeConfig(baseConfig, { + server: { + proxy: { + '/agentengine': { + target: fixtureTarget, + changeOrigin: true, + }, + '/api': { + target: fixtureTarget, + changeOrigin: true, + }, + }, + }, +}); From 5bb9c593e7fe381be6e7769f4619033d5c76036f Mon Sep 17 00:00:00 2001 From: xiayu Date: Fri, 28 Aug 2026 05:18:37 +0800 Subject: [PATCH 07/24] test(conversation): prove canonical input consumers --- e2e/canonical-conversation.spec.ts | 208 +++++++++++++++++- .../canonical-conversation-server.mjs | 76 ++++++- .../custom-conversation-consumer.html | 21 ++ e2e/fixtures/custom-conversation-consumer.ts | 112 ++++++++++ vite.canonical-conversation.config.mjs | 10 + 5 files changed, 416 insertions(+), 11 deletions(-) create mode 100644 e2e/fixtures/custom-conversation-consumer.html create mode 100644 e2e/fixtures/custom-conversation-consumer.ts diff --git a/e2e/canonical-conversation.spec.ts b/e2e/canonical-conversation.spec.ts index 171dabd..d3c8f20 100644 --- a/e2e/canonical-conversation.spec.ts +++ b/e2e/canonical-conversation.spec.ts @@ -1,11 +1,20 @@ -import { expect, test, type APIRequestContext } from '@playwright/test'; +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 }>; @@ -19,6 +28,33 @@ async function fixtureState(request: APIRequestContext): Promise { 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); @@ -31,10 +67,7 @@ test('canonical Hosted UI survives replay and renders every durable item safely' a2uiReplayErrors.push(message.text()); } }); - await page.goto('/'); - await expect( - page.getByRole('main').getByText('Canonical Fixture', { exact: true }), - ).toBeVisible(); + await openCanonicalHostedUi(page); const composer = page.locator('textarea[placeholder^="发送消息"]'); await composer.fill('执行第一轮 canonical 会话'); @@ -136,3 +169,168 @@ test('canonical Hosted UI survives replay and renders every durable item safely' }); 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', + approvalMode: '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"]')).toContainText( + 'Unsupported content: This content type is not supported', + ); + + 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 index a219bcb..c262c48 100644 --- a/e2e/fixtures/canonical-conversation-server.mjs +++ b/e2e/fixtures/canonical-conversation-server.mjs @@ -15,7 +15,11 @@ const delay = (milliseconds) => new Promise((resolve) => { function createState() { return { + config: { + attachmentInputs: true, + }, inputs: [], + uploads: [], streamPosts: 0, legacyRunAgentCalls: 0, reconnects: [], @@ -39,6 +43,12 @@ function sendJson(response, value, status = 200) { 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, @@ -52,10 +62,36 @@ function receipt(status, commandId, error = null) { } async function readJson(request) { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - if (chunks.length === 0) return {}; - return JSON.parse(Buffer.concat(chunks).toString('utf8')); + 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() { @@ -63,7 +99,7 @@ function bootstrap() { Agent: { AgentId: AGENT_ID, Name: 'Canonical Fixture', Framework: 'codex' }, ApiFormats: ['responses'], Capabilities: { - Attachments: false, + Attachments: true, WorkspaceFiles: false, Approval: true, Thinking: true, @@ -97,6 +133,10 @@ function surface() { { 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' }, @@ -371,6 +411,10 @@ async function streamSecondTurn(response) { 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') { @@ -422,7 +466,14 @@ async function handleAgentApi(request, response, requestUrl) { Page: 1, PageSize: 30, }, - ListAgentModels: { Models: [{ id: 'fixture-model', display_name: 'Fixture Model' }] }, + 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 }, @@ -446,9 +497,22 @@ const server = createServer(async (request, response) => { 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, 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/vite.canonical-conversation.config.mjs b/vite.canonical-conversation.config.mjs index 115ef67..6f03c9e 100644 --- a/vite.canonical-conversation.config.mjs +++ b/vite.canonical-conversation.config.mjs @@ -1,10 +1,20 @@ import { mergeConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; import baseConfig from './vite.config.ts'; const fixtureTarget = 'http://127.0.0.1:4182'; export default mergeConfig(baseConfig, { + resolve: { + // The independent browser fixture deliberately imports the documented + // package subpath instead of reaching into Hosted UI implementation code. + alias: { + '@kingsoftcloud/ksadk-web/conversation': fileURLToPath( + new URL('./src/public/conversation.ts', import.meta.url), + ), + }, + }, server: { proxy: { '/agentengine': { From 4f2ea019119308f03ca373a8207e49c573beb9b3 Mon Sep 17 00:00:00 2001 From: xiayu Date: Fri, 28 Aug 2026 05:44:52 +0800 Subject: [PATCH 08/24] chore(release): gate packed conversation artifacts --- .github/workflows/publish-npm.yml | 6 +- CHANGELOG.md | 9 + README.md | 29 +++- package.json | 6 +- schemas/release-provenance.schema.json | 34 ++++ scripts/release-preflight.mjs | 131 +++++++++++++++ scripts/release-provenance.mjs | 224 +++++++++++++++++++++++++ scripts/verify-packed-conversation.mjs | 61 +++++++ tests/package-contract.test.mjs | 7 + tests/release-provenance.test.mjs | 121 +++++++++++++ 10 files changed, 619 insertions(+), 9 deletions(-) create mode 100644 schemas/release-provenance.schema.json create mode 100644 scripts/release-preflight.mjs create mode 100644 scripts/release-provenance.mjs create mode 100644 scripts/verify-packed-conversation.mjs create mode 100644 tests/release-provenance.test.mjs 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 1a64fef..14b4e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ - 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 diff --git a/README.md b/README.md index 314099a..d721bb8 100644 --- a/README.md +++ b/README.md @@ -141,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/package.json b/package.json index 1903733..3f95c2e 100644 --- a/package.json +++ b/package.json @@ -12,12 +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": { ".": { @@ -52,6 +55,7 @@ "README.md", "CHANGELOG.md", "RELEASE_PROVENANCE.json", + "schemas", "LICENSE", "package.json" ], 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/tests/package-contract.test.mjs b/tests/package-contract.test.mjs index d1a0e81..748a589 100644 --- a/tests/package-contract.test.mjs +++ b/tests/package-contract.test.mjs @@ -11,6 +11,9 @@ test('package metadata exposes release artifacts and public entrypoints', () => assert.deepEqual(packageJson.publishConfig, { access: 'public' }); assert.equal(packageJson.scripts['build:lib'], 'vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json'); assert.equal(packageJson.scripts['build:all'], 'npm run build:ksadk && npm run build:hosted && npm run build:lib'); + assert.equal(packageJson.scripts['test:node'], 'node --test tests/*.test.mjs'); + assert.equal(packageJson.scripts['release:provenance'], 'node scripts/release-provenance.mjs'); + assert.equal(packageJson.scripts['release:preflight'], 'node scripts/release-preflight.mjs'); assert.deepEqual(Object.keys(packageJson.exports).sort(), [ '.', @@ -35,6 +38,8 @@ test('package metadata exposes release artifacts and public entrypoints', () => assert.ok(packageJson.files.includes('README.md')); assert.ok(packageJson.files.includes('CHANGELOG.md')); assert.ok(packageJson.files.includes('LICENSE')); + assert.ok(packageJson.files.includes('RELEASE_PROVENANCE.json')); + assert.ok(packageJson.files.includes('schemas')); }); test('react is a peer dependency for hosted-ui consumers', () => { @@ -50,6 +55,8 @@ test('npm publishing uses trusted publishing instead of repository tokens', () = assert.match(publishWorkflow, /id-token:\s+write/); assert.match(publishWorkflow, /npm publish --access public --provenance/); assert.doesNotMatch(publishWorkflow, /NODE_AUTH_TOKEN|NPM_TOKEN/); + assert.match(publishWorkflow, /playwright install --with-deps chromium/); + assert.match(publishWorkflow, /npm run release:preflight/); }); test('successful npm release automatically deploys the matching Pages bundle', () => { diff --git a/tests/release-provenance.test.mjs b/tests/release-provenance.test.mjs new file mode 100644 index 0000000..5b47c37 --- /dev/null +++ b/tests/release-provenance.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { + checkReleaseProvenance, + generateReleaseProvenance, + validateReleaseProvenance, +} from '../scripts/release-provenance.mjs'; + +const DIGEST = 'a'.repeat(64); + +function git(cwd, ...args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); +} + +async function withRepository(run) { + const root = await mkdtemp(resolve(tmpdir(), 'ksadk-web-provenance-')); + try { + git(root, 'init', '--quiet'); + git(root, 'config', 'user.email', 'release-test@example.invalid'); + git(root, 'config', 'user.name', 'Release Test'); + await writeFile( + resolve(root, 'package.json'), + `${JSON.stringify({ name: '@kingsoftcloud/ksadk-web', version: '0.3.3' }, null, 2)}\n`, + ); + await writeFile(resolve(root, 'README.md'), 'fixture\n'); + git(root, 'add', '.'); + git(root, 'commit', '--quiet', '-m', 'freeze candidate'); + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test('release provenance schema is strict and rejects fabricated fields', () => { + const valid = { + schema_version: 1, + package: '@kingsoftcloud/ksadk-web', + version: '0.3.3', + source_commit: 'b'.repeat(40), + interaction_contract_digest: DIGEST, + }; + assert.equal(validateReleaseProvenance(valid), valid); + assert.throws( + () => validateReleaseProvenance({ ...valid, source_commit: 'HEAD' }), + /full lowercase Git SHA/, + ); + assert.throws( + () => validateReleaseProvenance({ ...valid, generated_at: 'whenever' }), + /keys must be exactly/, + ); +}); + +test('published JSON schema describes the executable provenance contract', async () => { + const schema = JSON.parse(await readFile( + resolve(import.meta.dirname, '../schemas/release-provenance.schema.json'), + 'utf8', + )); + assert.equal(schema.additionalProperties, false); + assert.deepEqual([...schema.required].sort(), [ + 'interaction_contract_digest', + 'package', + 'schema_version', + 'source_commit', + 'version', + ]); + assert.equal(schema.properties.schema_version.const, 1); + assert.equal(schema.properties.package.const, '@kingsoftcloud/ksadk-web'); + assert.match('0.3.3', new RegExp(schema.properties.version.pattern)); + assert.match('b'.repeat(40), new RegExp(schema.properties.source_commit.pattern)); + assert.match(DIGEST, new RegExp(schema.properties.interaction_contract_digest.pattern)); +}); + +test('the unreleased branch preserves the immutable tagged provenance', async () => { + const result = await checkReleaseProvenance({ + repoRoot: resolve(import.meta.dirname, '..'), + allowUnreleased: true, + }); + assert.equal(result.tag, 'v0.3.2'); + assert.equal(result.tagExists, true); + assert.equal(result.currentAheadOfPublishedTag, true); +}); + +test('formal provenance generation uses a clean frozen commit and cannot re-sign a tag', async () => { + await withRepository(async (root) => { + const frozenCommit = git(root, 'rev-parse', 'HEAD'); + const generated = await generateReleaseProvenance({ + repoRoot: root, + interactionContractDigest: DIGEST, + }); + assert.equal(generated.source_commit, frozenCommit); + assert.deepEqual( + JSON.parse(await readFile(resolve(root, 'RELEASE_PROVENANCE.json'), 'utf8')), + generated, + ); + + git(root, 'add', 'RELEASE_PROVENANCE.json'); + git(root, 'commit', '--quiet', '-m', 'attest candidate'); + const checked = await checkReleaseProvenance({ repoRoot: root }); + assert.equal(checked.sourceCommit, frozenCommit); + + git(root, 'tag', 'v0.3.3'); + await assert.rejects( + generateReleaseProvenance({ repoRoot: root, interactionContractDigest: DIGEST }), + /already tagged version/, + ); + }); +}); + +test('formal provenance generation refuses a dirty candidate', async () => { + await withRepository(async (root) => { + await writeFile(resolve(root, 'README.md'), 'dirty\n'); + await assert.rejects( + generateReleaseProvenance({ repoRoot: root, interactionContractDigest: DIGEST }), + /dirty worktree/, + ); + }); +}); From afb20275de4e531b87c580979745e773712b9be0 Mon Sep 17 00:00:00 2001 From: xiayu Date: Fri, 28 Aug 2026 07:27:56 +0800 Subject: [PATCH 09/24] chore(release): prepare ksadk-web 0.3.3 --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b4e13..c10b2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.3.3 - 2026-08-28 ### Headless conversation surface 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 3f95c2e..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", From 233da627cae12b0fd9e96cdc9a7387d067346f42 Mon Sep 17 00:00:00 2001 From: xiayu Date: Fri, 28 Aug 2026 07:28:03 +0800 Subject: [PATCH 10/24] chore(release): attest ksadk-web 0.3.3 source --- RELEASE_PROVENANCE.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index f87558f..74294df 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": "afb20275de4e531b87c580979745e773712b9be0", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" } From fa9160664fe5b018f6a2eb3527f7d4e332f3d58b Mon Sep 17 00:00:00 2001 From: xiayu Date: Fri, 28 Aug 2026 07:28:46 +0800 Subject: [PATCH 11/24] test(release): expect untagged 0.3.3 candidate --- tests/release-provenance.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/release-provenance.test.mjs b/tests/release-provenance.test.mjs index 5b47c37..f1b81bd 100644 --- a/tests/release-provenance.test.mjs +++ b/tests/release-provenance.test.mjs @@ -74,14 +74,14 @@ test('published JSON schema describes the executable provenance contract', async assert.match(DIGEST, new RegExp(schema.properties.interaction_contract_digest.pattern)); }); -test('the unreleased branch preserves the immutable tagged provenance', async () => { +test('the 0.3.3 candidate remains untagged until the protected release', async () => { const result = await checkReleaseProvenance({ repoRoot: resolve(import.meta.dirname, '..'), allowUnreleased: true, }); - assert.equal(result.tag, 'v0.3.2'); - assert.equal(result.tagExists, true); - assert.equal(result.currentAheadOfPublishedTag, true); + assert.equal(result.tag, 'v0.3.3'); + assert.equal(result.tagExists, false); + assert.equal(result.currentAheadOfPublishedTag, false); }); test('formal provenance generation uses a clean frozen commit and cannot re-sign a tag', async () => { From 34bb92d7c062f13242c96c931078207538e2dd1b Mon Sep 17 00:00:00 2001 From: xiayu Date: Fri, 28 Aug 2026 07:28:53 +0800 Subject: [PATCH 12/24] chore(release): refresh ksadk-web 0.3.3 attestation --- RELEASE_PROVENANCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index 74294df..9959384 100644 --- a/RELEASE_PROVENANCE.json +++ b/RELEASE_PROVENANCE.json @@ -2,6 +2,6 @@ "schema_version": 1, "package": "@kingsoftcloud/ksadk-web", "version": "0.3.3", - "source_commit": "afb20275de4e531b87c580979745e773712b9be0", + "source_commit": "fa9160664fe5b018f6a2eb3527f7d4e332f3d58b", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" } From 9b1c2b3ce076baaf90d169c5ae08b6803b614889 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 00:22:01 +0800 Subject: [PATCH 13/24] fix(conversation): preserve ordered shared renderer identity --- CHANGELOG.md | 6 ++ src/__tests__/conversation-protocol.test.ts | 50 +++++++++++++++ .../conversation-renderer-registry.test.ts | 45 ++++++++++++++ src/__tests__/hosted-conversation.test.ts | 36 ++++++++--- src/core/conversation/hosted.ts | 5 +- src/core/conversation/index.ts | 6 ++ src/core/conversation/presentation.ts | 61 +++++++++++++++++++ src/core/conversation/renderer-registry.ts | 54 ++++++++++++++++ src/core/conversation/types.ts | 13 ++++ src/public/conversation.ts | 4 ++ 10 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/conversation-renderer-registry.test.ts create mode 100644 src/core/conversation/renderer-registry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c10b2c4..4c49269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ replayed `(itemId, sourceEventId)` pairs, keep terminal items monotonic, and safely downgrade unknown item kinds or schema versions to passive fallback content. +- 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 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. diff --git a/src/__tests__/conversation-protocol.test.ts b/src/__tests__/conversation-protocol.test.ts index d53fcd3..da27909 100644 --- a/src/__tests__/conversation-protocol.test.ts +++ b/src/__tests__/conversation-protocol.test.ts @@ -160,6 +160,56 @@ describe('ConversationItem/v1 identity reducer', () => { }); 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('degrades future kinds and payload schemas without executing their payload', () => { const unknownKind = decodedItem({ itemId: 'future-kind', 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 index 6f51969..cee653c 100644 --- a/src/__tests__/hosted-conversation.test.ts +++ b/src/__tests__/hosted-conversation.test.ts @@ -107,12 +107,22 @@ describe('Hosted UI canonical ConversationItem projection', () => { callId: 'call-1', tool: 'read_file', args: { path: 'README.md' }, - output: { ok: true }, }, )), frame(5, item( - 'approval-item-1', + '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', { @@ -124,23 +134,23 @@ describe('Hosted UI canonical ConversationItem projection', () => { }, { lifecycle: 'pending' }, )), - frame(6, item( + frame(7, item( 'a2ui-1', - 'event-6', + 'event-7', 'a2ui', 'conversation.item.a2ui/v1', { data: operations }, )), - frame(7, item( + frame(8, item( 'future-1', - 'event-7', + 'event-8', 'game_board', 'vendor.game-board/v7', { html: '' }, )), - frame(8, item( + frame(9, item( 'run-terminal', - 'event-8', + 'event-9', 'progress', 'conversation.item.progress/v1', {}, @@ -180,6 +190,7 @@ describe('Hosted UI canonical ConversationItem projection', () => { 'answer-2', 'reasoning-1', 'tool-1', + 'tool-result-1', 'approval-item-1', 'a2ui-1', 'future-1', @@ -192,7 +203,14 @@ describe('Hosted UI canonical ConversationItem projection', () => { 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' })]); + .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 }); expect(messages.find((message) => message.itemId === 'future-1')).toMatchObject({ diff --git a/src/core/conversation/hosted.ts b/src/core/conversation/hosted.ts index b8663a4..30390d1 100644 --- a/src/core/conversation/hosted.ts +++ b/src/core/conversation/hosted.ts @@ -275,7 +275,10 @@ export function projectConversationStreamForHostedUi( const messages: Message[] = []; const interactions: Interaction[] = []; - for (const item of result.state.items) { + // The shared presentation is the only place allowed to combine related + // native items (for example tool_call and tool_result by callId). Iterating + // raw state here would reintroduce duplicate cards in Hosted UI. + for (const { item } of presentation.timeline) { if (textById.has(item.itemId)) { messages.push(textMessage(item)); continue; diff --git a/src/core/conversation/index.ts b/src/core/conversation/index.ts index b46a094..7d2421f 100644 --- a/src/core/conversation/index.ts +++ b/src/core/conversation/index.ts @@ -14,6 +14,11 @@ export { reduceConversationItem, } from './reducer.js'; export { projectConversationItems } from './presentation.js'; +export { + createTrustedRendererCatalog, + type TrustedConversationRenderer, + type TrustedRendererCatalog, +} from './renderer-registry.js'; export type { ConversationArtifact, ConversationCapability, @@ -41,5 +46,6 @@ export type { ConversationStreamResult, ConversationStreamTurnOptions, ConversationTextPart, + ConversationTimelineEntry, ConversationTextPresentation, } from './types.js'; diff --git a/src/core/conversation/presentation.ts b/src/core/conversation/presentation.ts index ffc20ec..3b342a6 100644 --- a/src/core/conversation/presentation.ts +++ b/src/core/conversation/presentation.ts @@ -6,6 +6,7 @@ import type { ConversationPresentation, ConversationProjectionOptions, ConversationTextPresentation, + ConversationTimelineEntry, } from './types.js'; const SUPPORTED_SCHEMAS: Partial> = { @@ -67,6 +68,65 @@ function projectArtifact(item: ConversationItem): ConversationArtifact { }; } +function payloadString(item: ConversationItem, field: string): string | null { + const value = item.payload[field]; + return typeof value === 'string' && value ? value : null; +} + +function presentationKey(item: ConversationItem): string { + if (item.kind === 'tool_call') { + const callId = payloadString(item, 'callId'); + if (callId) return `tool:${callId}`; + } + if (item.kind === 'approval') { + const interactionId = payloadString(item, 'interactionId'); + if (interactionId) return `approval:${interactionId}`; + } + if (item.kind === 'a2ui') { + const surfaceId = payloadString(item, 'surfaceId'); + if (surfaceId) return `a2ui:${surfaceId}`; + } + return `item:${item.itemId}`; +} + +function mergeTimelineItem( + previous: ConversationItem, + incoming: ConversationItem, +): ConversationItem { + const preserveTerminal = terminal(previous) && !terminal(incoming); + return { + ...previous, + ...incoming, + itemId: previous.itemId, + parentItemId: previous.parentItemId, + sourceEventIds: [...new Set([...previous.sourceEventIds, ...incoming.sourceEventIds])], + payload: { ...previous.payload, ...incoming.payload }, + nativeRef: { ...previous.nativeRef, ...incoming.nativeRef }, + ...(preserveTerminal ? { lifecycle: previous.lifecycle, operation: previous.operation } : {}), + }; +} + +function projectTimeline(items: ConversationItem[]): ConversationTimelineEntry[] { + const entries: ConversationTimelineEntry[] = []; + const indices = new Map(); + for (const item of items) { + const key = presentationKey(item); + const index = indices.get(key); + if (index === undefined) { + indices.set(key, entries.length); + entries.push({ key, item, sourceItemIds: [item.itemId] }); + continue; + } + const previous = entries[index]; + entries[index] = { + key, + item: mergeTimelineItem(previous.item, item), + sourceItemIds: [...new Set([...previous.sourceItemIds, item.itemId])], + }; + } + return entries; +} + /** * Produce passive renderer data. Unknown kinds or payload schema versions are * converted to fallback cards; A2UI and approvals remain typed data and are @@ -100,6 +160,7 @@ export function projectConversationItems( )); return { + timeline: projectTimeline(supported.filter((item) => item.kind !== 'progress')), textItems, toolItems: supported.filter((item) => item.kind === 'tool_call'), approvalItems: supported.filter((item) => item.kind === 'approval'), diff --git a/src/core/conversation/renderer-registry.ts b/src/core/conversation/renderer-registry.ts new file mode 100644 index 0000000..60853c7 --- /dev/null +++ b/src/core/conversation/renderer-registry.ts @@ -0,0 +1,54 @@ +import type { ConversationItem, ConversationItemKind } from './types.js'; + +/** + * A renderer declaration compiled into the trusted frontend bundle. + * + * This is deliberately an immutable catalog, not a runtime plugin API. A + * Provider may produce a schema only after the host frontend ships its + * matching renderer; Runtime payloads never supply executable renderer code. + */ +export type TrustedConversationRenderer = { + id: string; + schemaRef: string; + kinds: readonly ConversationItemKind[]; +}; + +export type TrustedRendererCatalog = { + resolve(item: ConversationItem): TrustedConversationRenderer | undefined; + entries(): readonly TrustedConversationRenderer[]; +}; + +function nonEmpty(value: string, field: string): void { + if (!value.trim()) throw new Error(`trusted conversation renderer ${field} must not be empty`); +} + +/** Build and freeze the host's exact kind/schema dispatch table once. */ +export function createTrustedRendererCatalog( + renderers: readonly TrustedConversationRenderer[], +): TrustedRendererCatalog { + const bySchema = new Map(); + for (const renderer of renderers) { + nonEmpty(renderer.id, 'id'); + nonEmpty(renderer.schemaRef, 'schemaRef'); + if (!renderer.kinds.length) { + throw new Error('trusted conversation renderer kinds must not be empty'); + } + if (bySchema.has(renderer.schemaRef)) { + throw new Error(`duplicate trusted conversation renderer schemaRef: ${renderer.schemaRef}`); + } + bySchema.set(renderer.schemaRef, Object.freeze({ + ...renderer, + kinds: Object.freeze([...renderer.kinds]), + })); + } + const entries = Object.freeze([...bySchema.values()]); + return Object.freeze({ + resolve(item) { + const renderer = bySchema.get(item.payloadSchemaRef); + return renderer?.kinds.includes(item.kind) ? renderer : undefined; + }, + entries() { + return entries; + }, + }); +} diff --git a/src/core/conversation/types.ts b/src/core/conversation/types.ts index eda1bae..2561b6a 100644 --- a/src/core/conversation/types.ts +++ b/src/core/conversation/types.ts @@ -145,11 +145,24 @@ export type ConversationArtifact = { uri: string | null; }; +/** + * One ordered renderer node. `key` is a presentation identity only: raw + * RuntimeEvent-derived items remain represented by `sourceItemIds` for replay + * and audit. A tool result may enrich the earlier tool-call node by callId. + */ +export type ConversationTimelineEntry = { + key: string; + item: ConversationItem; + sourceItemIds: string[]; +}; + /** * Headless, renderer-ready data. This projection never executes item payloads * and deliberately retains item identities for text and reasoning content. */ export type ConversationPresentation = { + /** Canonical visible order; renderers must not reconstruct it from groups. */ + timeline: ConversationTimelineEntry[]; textItems: ConversationTextPresentation[]; toolItems: ConversationItem[]; approvalItems: ConversationItem[]; diff --git a/src/public/conversation.ts b/src/public/conversation.ts index 9c119bc..5f7184f 100644 --- a/src/public/conversation.ts +++ b/src/public/conversation.ts @@ -17,6 +17,7 @@ export { preflightConversationInput, projectConversationItems, reduceConversationItem, + createTrustedRendererCatalog, surfacePermitsInput, } from '../core/conversation/index.js'; export type { @@ -48,4 +49,7 @@ export type { ConversationStreamTurnOptions, ConversationTextPart, ConversationTextPresentation, + ConversationTimelineEntry, + TrustedConversationRenderer, + TrustedRendererCatalog, } from '../core/conversation/index.js'; From db3f1a66e49c3b17da99479f0827a455e60fbaf4 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 00:22:51 +0800 Subject: [PATCH 14/24] chore(release): refresh ksadk-web 0.3.3 attestation --- RELEASE_PROVENANCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index 9959384..05520a1 100644 --- a/RELEASE_PROVENANCE.json +++ b/RELEASE_PROVENANCE.json @@ -2,6 +2,6 @@ "schema_version": 1, "package": "@kingsoftcloud/ksadk-web", "version": "0.3.3", - "source_commit": "fa9160664fe5b018f6a2eb3527f7d4e332f3d58b", + "source_commit": "9b1c2b3ce076baaf90d169c5ae08b6803b614889", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" } From 54517c61011dd4e93eb6c19e944c83fe3eea8b7b Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 00:42:48 +0800 Subject: [PATCH 15/24] fix(conversation): preserve summary compatibility --- CHANGELOG.md | 3 +++ src/__tests__/conversation-protocol.test.ts | 5 ++++- src/core/conversation/presentation.ts | 6 ++++++ src/core/conversation/types.ts | 6 ++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c49269..4942746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ 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. diff --git a/src/__tests__/conversation-protocol.test.ts b/src/__tests__/conversation-protocol.test.ts index da27909..81c90d5 100644 --- a/src/__tests__/conversation-protocol.test.ts +++ b/src/__tests__/conversation-protocol.test.ts @@ -108,8 +108,11 @@ describe('ConversationItem/v1 identity reducer', () => { sourceEventIds: ['event-2'], payload: { text: ' world' }, }))).toBe(true); - expect(projectConversationItems(reducer.snapshot()).textItems[0]?.text) + 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', () => { diff --git a/src/core/conversation/presentation.ts b/src/core/conversation/presentation.ts index 3b342a6..5580c73 100644 --- a/src/core/conversation/presentation.ts +++ b/src/core/conversation/presentation.ts @@ -150,6 +150,10 @@ export function projectConversationItems( const textItems = supported .filter((item) => textKinds.has(item.kind)) .map(projectTextItem); + const textSummary = (kind: ConversationItemKind): string => supported + .filter((item) => item.kind === kind) + .map((item) => typeof item.payload.text === 'string' ? item.payload.text : '') + .join(''); const fallbackItems = [ ...supported.filter((item) => item.kind === 'unknown'), ...unsupported, @@ -161,6 +165,8 @@ export function projectConversationItems( return { timeline: projectTimeline(supported.filter((item) => item.kind !== 'progress')), + output: textSummary('assistant_text'), + reasoning: textSummary('reasoning'), textItems, toolItems: supported.filter((item) => item.kind === 'tool_call'), approvalItems: supported.filter((item) => item.kind === 'approval'), diff --git a/src/core/conversation/types.ts b/src/core/conversation/types.ts index 2561b6a..60e9476 100644 --- a/src/core/conversation/types.ts +++ b/src/core/conversation/types.ts @@ -163,6 +163,12 @@ export type ConversationTimelineEntry = { export type ConversationPresentation = { /** Canonical visible order; renderers must not reconstruct it from groups. */ timeline: ConversationTimelineEntry[]; + /** + * Compatibility summaries for consumers that have not yet moved to + * `timeline` / `textItems`. They never participate in identity reduction. + */ + output: string; + reasoning: string; textItems: ConversationTextPresentation[]; toolItems: ConversationItem[]; approvalItems: ConversationItem[]; From b086cf00be9386fb0fbca8db446a4ab460385415 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 00:42:48 +0800 Subject: [PATCH 16/24] chore(release): refresh ksadk-web 0.3.3 provenance --- RELEASE_PROVENANCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index 05520a1..0690fef 100644 --- a/RELEASE_PROVENANCE.json +++ b/RELEASE_PROVENANCE.json @@ -2,6 +2,6 @@ "schema_version": 1, "package": "@kingsoftcloud/ksadk-web", "version": "0.3.3", - "source_commit": "9b1c2b3ce076baaf90d169c5ae08b6803b614889", + "source_commit": "54517c61011dd4e93eb6c19e944c83fe3eea8b7b", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" } From 331b4536555f6af280da35b4f40d3cb5104862ba Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 03:23:21 +0800 Subject: [PATCH 17/24] fix(conversation): hide additive unknown items --- CHANGELOG.md | 5 +++-- src/__tests__/conversation-protocol.test.ts | 8 ++++---- src/core/conversation/contracts.ts | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4942746..8520627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,9 @@ 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 - safely downgrade unknown item kinds or schema versions to passive fallback - content. + 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 diff --git a/src/__tests__/conversation-protocol.test.ts b/src/__tests__/conversation-protocol.test.ts index 81c90d5..fc97817 100644 --- a/src/__tests__/conversation-protocol.test.ts +++ b/src/__tests__/conversation-protocol.test.ts @@ -213,7 +213,7 @@ describe('ConversationItem/v1 renderer projection', () => { }); }); - it('degrades future kinds and payload schemas without executing their payload', () => { + it('hides future kinds and degrades payload schemas without executing their payload', () => { const unknownKind = decodedItem({ itemId: 'future-kind', sourceEventIds: ['future-event'], @@ -232,10 +232,10 @@ describe('ConversationItem/v1 renderer projection', () => { const presentation = projectConversationItems(state); expect(presentation.textItems).toEqual([]); - expect(presentation.fallbacks).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: 'future-kind', title: 'Unsupported content' }), + expect(unknownKind.visibility).toBe('hidden'); + expect(presentation.fallbacks).toEqual([ expect.objectContaining({ id: 'future-schema', title: 'Unsupported content' }), - ])); + ]); expect(unknownKind.payload).not.toHaveProperty('html'); }); diff --git a/src/core/conversation/contracts.ts b/src/core/conversation/contracts.ts index e529bb8..930a729 100644 --- a/src/core/conversation/contracts.ts +++ b/src/core/conversation/contracts.ts @@ -301,9 +301,11 @@ export function decodeConversationSurface(value: unknown): ConversationSurface | } /** - * Decode ConversationItem/v1. Future item kinds are retained as a passive - * `unknown` card; an unknown contract version or unsafe structural field is - * rejected instead of being guessed. + * Decode ConversationItem/v1. Future item kinds remain inspectable in the + * canonical stream but are hidden from the default transcript. A client must + * never turn an additive provider event into a repeating user-facing fallback + * card. Unknown contract versions or unsafe structural fields are rejected + * instead of being guessed. */ export function decodeConversationItem(value: unknown): ConversationItem | null { const raw = record(value); @@ -357,7 +359,13 @@ export function decodeConversationItem(value: unknown): ConversationItem | null kind, operation, lifecycle, - visibility: (raw.visibility || 'public') as ConversationItemVisibility, + // Keep the raw source item for replay/audit while matching the backend + // projector: an additive, unregistered kind is not a chat card. Known + // kinds with a newer payload schema remain visible as a single passive + // fallback, so a schema upgrade is diagnosable without executing it. + visibility: kind === 'unknown' && originalKind !== 'unknown' + ? 'hidden' + : (raw.visibility || 'public') as ConversationItemVisibility, payloadSchemaRef: raw.payloadSchemaRef, payload: kind === 'unknown' && originalKind !== 'unknown' ? { From 2a974096472ff74f3c70ee925e2bc82c7cbb52ae Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 03:24:21 +0800 Subject: [PATCH 18/24] test(conversation): cover hidden additive events --- src/__tests__/hosted-conversation.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/__tests__/hosted-conversation.test.ts b/src/__tests__/hosted-conversation.test.ts index cee653c..d26d9f1 100644 --- a/src/__tests__/hosted-conversation.test.ts +++ b/src/__tests__/hosted-conversation.test.ts @@ -63,7 +63,7 @@ function stream(body: string): Response { } describe('Hosted UI canonical ConversationItem projection', () => { - it('uses one identity reducer across reconnect for text, reasoning, tool, approval, A2UI and fallback', async () => { + 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' }, @@ -213,10 +213,9 @@ describe('Hosted UI canonical ConversationItem projection', () => { ))).toHaveLength(1); expect(messages.find((message) => message.itemId === 'a2ui-1')?.aguiActivity) .toEqual({ surfaceId: 'profile-form', messages: operations }); - expect(messages.find((message) => message.itemId === 'future-1')).toMatchObject({ - role: 'system', - content: expect.stringContaining('Unsupported content'), - }); + // 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('', { exact: true })).toHaveCount(0); const tray = page.getByTestId('interaction-tray'); @@ -290,9 +291,7 @@ test('independent custom frontend consumes the public conversation API across re 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"]')).toContainText( - 'Unsupported content: This content type is not supported', - ); + await expect(page.locator('[data-kind="fallback"]')).toHaveCount(0); await page.getByRole('button', { name: 'Approve' }).click(); await expect(page.getByRole('status')).toHaveText('completed-1'); From 2292fbea47f4bcd8b3b05bc1192b70c594d5814c Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 03:25:47 +0800 Subject: [PATCH 21/24] chore(release): refresh ksadk-web 0.3.3 provenance --- RELEASE_PROVENANCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index 49b8ec2..89e2b90 100644 --- a/RELEASE_PROVENANCE.json +++ b/RELEASE_PROVENANCE.json @@ -2,6 +2,6 @@ "schema_version": 1, "package": "@kingsoftcloud/ksadk-web", "version": "0.3.3", - "source_commit": "2a974096472ff74f3c70ee925e2bc82c7cbb52ae", + "source_commit": "8f6a91b3137c5fcfc9edfc3e85bdc6b1c8289aa5", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" } From 84d02ac51a0a9a716849495d37c2c972374f50cb Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 08:26:13 +0800 Subject: [PATCH 22/24] fix(web): keep provider controls in conversation extensions --- src/__tests__/conversation-client.test.ts | 10 +++-- src/__tests__/run-engine.test.ts | 2 +- src/core/conversation/contracts.ts | 45 ++++++++++------------- src/core/conversation/types.ts | 3 -- src/core/run/engine.ts | 22 ++++++----- 5 files changed, 38 insertions(+), 44 deletions(-) diff --git a/src/__tests__/conversation-client.test.ts b/src/__tests__/conversation-client.test.ts index 9c4b265..a185e6d 100644 --- a/src/__tests__/conversation-client.test.ts +++ b/src/__tests__/conversation-client.test.ts @@ -85,10 +85,12 @@ describe('ConversationInput/v1', () => { ], modelRef: 'model:example', reasoning: 'high', - approvalMode: 'risk', - collaborationMode: 'plan', - goalObjective: 'finish the task', - extensions: { 'vendor.preview': true }, + extensions: { + 'ksadk.approval': 'risk', + 'ksadk.collaboration': 'plan', + 'ksadk.goal': 'finish the task', + 'vendor.preview': true, + }, }); expect(built).toMatchObject({ diff --git a/src/__tests__/run-engine.test.ts b/src/__tests__/run-engine.test.ts index 0edd8a5..a4b99a6 100644 --- a/src/__tests__/run-engine.test.ts +++ b/src/__tests__/run-engine.test.ts @@ -188,7 +188,7 @@ describe('RunEngineImpl', () => { sessionId: 'session-canonical', parts: [{ kind: 'text', text: 'hello canonical' }], modelRef: 'model-canonical', - approvalMode: 'risk', + extensions: { 'ksadk.approval': 'risk' }, }); options.onUpdate?.(result); return result; diff --git a/src/core/conversation/contracts.ts b/src/core/conversation/contracts.ts index 930a729..20f8582 100644 --- a/src/core/conversation/contracts.ts +++ b/src/core/conversation/contracts.ts @@ -61,9 +61,6 @@ const INPUT_KEYS = new Set([ 'parts', 'modelRef', 'reasoning', - 'approvalMode', - 'collaborationMode', - 'goalObjective', 'extensions', ]); const TEXT_PART_KEYS = new Set(['kind', 'text']); @@ -73,8 +70,9 @@ const ATTACHMENT_PART_KEYS = new Set([ 'mediaType', 'name', ]); -const APPROVAL_MODES = new Set(['ask', 'risk', 'full']); -const COLLABORATION_MODES = new Set(['default', 'plan']); +const APPROVAL_MODE_EXTENSION = 'ksadk.approval'; +const COLLABORATION_MODE_EXTENSION = 'ksadk.collaboration'; +const GOAL_OBJECTIVE_EXTENSION = 'ksadk.goal'; function record(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) @@ -136,6 +134,14 @@ function decodeExtensions(value: unknown): Record | null { ))) { return null; } + const approval = extensions[APPROVAL_MODE_EXTENSION]; + const collaboration = extensions[COLLABORATION_MODE_EXTENSION]; + const goal = extensions[GOAL_OBJECTIVE_EXTENSION]; + if ((approval !== undefined && !['ask', 'risk', 'full'].includes(String(approval))) + || (collaboration !== undefined && !['default', 'plan'].includes(String(collaboration))) + || (goal !== undefined && !boundedString(goal, 4_096))) { + return null; + } return { ...extensions }; } @@ -152,14 +158,7 @@ export function decodeConversationInput(value: unknown): ConversationInput | nul || !Array.isArray(raw.parts) || raw.parts.length === 0 || !optionalBoundedString(raw.modelRef, 256) - || !optionalBoundedString(raw.reasoning, 64) - || (raw.approvalMode !== undefined - && raw.approvalMode !== null - && !APPROVAL_MODES.has(String(raw.approvalMode))) - || (raw.collaborationMode !== undefined - && raw.collaborationMode !== null - && !COLLABORATION_MODES.has(String(raw.collaborationMode))) - || !optionalBoundedString(raw.goalObjective, 4_096)) { + || !optionalBoundedString(raw.reasoning, 64)) { return null; } const parts = raw.parts.map(decodeInputPart); @@ -174,15 +173,6 @@ export function decodeConversationInput(value: unknown): ConversationInput | nul parts: parts as ConversationInputPart[], ...(raw.modelRef === undefined ? {} : { modelRef: raw.modelRef as string | null }), ...(raw.reasoning === undefined ? {} : { reasoning: raw.reasoning as string | null }), - ...(raw.approvalMode === undefined - ? {} - : { approvalMode: raw.approvalMode as ConversationInput['approvalMode'] }), - ...(raw.collaborationMode === undefined - ? {} - : { collaborationMode: raw.collaborationMode as ConversationInput['collaborationMode'] }), - ...(raw.goalObjective === undefined - ? {} - : { goalObjective: raw.goalObjective as string | null }), ...(raw.extensions === undefined ? {} : { extensions }), }; } @@ -213,10 +203,13 @@ function requiredInputCapabilities(input: ConversationInput): string[] { )); if (input.modelRef) capabilities.push('model.select'); if (input.reasoning) capabilities.push('reasoning.effort'); - if (input.approvalMode) capabilities.push('approval'); - if (input.collaborationMode === 'plan') capabilities.push('plan'); - if (input.goalObjective) capabilities.push('goal'); - capabilities.push(...Object.keys(input.extensions || {})); + for (const key of Object.keys(input.extensions || {})) { + if (key === APPROVAL_MODE_EXTENSION) capabilities.push('approval'); + else if (key === COLLABORATION_MODE_EXTENSION) { + if (input.extensions?.[key] === 'plan') capabilities.push('plan'); + } else if (key === GOAL_OBJECTIVE_EXTENSION) capabilities.push('goal'); + else capabilities.push(key); + } return [...new Set(capabilities)]; } diff --git a/src/core/conversation/types.ts b/src/core/conversation/types.ts index 60e9476..5520895 100644 --- a/src/core/conversation/types.ts +++ b/src/core/conversation/types.ts @@ -53,9 +53,6 @@ export type ConversationInput = { parts: ConversationInputPart[]; modelRef?: string | null; reasoning?: string | null; - approvalMode?: 'ask' | 'risk' | 'full' | null; - collaborationMode?: 'default' | 'plan' | null; - goalObjective?: string | null; extensions?: Record; }; diff --git a/src/core/run/engine.ts b/src/core/run/engine.ts index bec37d4..9952f20 100644 --- a/src/core/run/engine.ts +++ b/src/core/run/engine.ts @@ -893,16 +893,18 @@ export class RunEngineImpl implements RunEngine { && surfacePermitsInput(bootstrap.surface, 'reasoning.effort') ? { reasoning: thinkingMode } : {}), - ...(this.config.permissionMode - && surfacePermitsInput(bootstrap.surface, 'approval') - ? { approvalMode: this.config.permissionMode } - : {}), - ...(draft.executionMode === 'plan' - ? { collaborationMode: 'plan' as const } - : {}), - ...(draft.executionMode === 'goal' && text - ? { goalObjective: text } - : {}), + extensions: { + ...(this.config.permissionMode + && surfacePermitsInput(bootstrap.surface, 'approval') + ? { 'ksadk.approval': this.config.permissionMode } + : {}), + ...(draft.executionMode === 'plan' + ? { 'ksadk.collaboration': 'plan' } + : {}), + ...(draft.executionMode === 'goal' && text + ? { 'ksadk.goal': text } + : {}), + }, }); return preflightConversationInput(bootstrap.surface, input); } From 37eb8dd6ae8c44ea4622d39f8c7ae9b13916a793 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 12:02:01 +0800 Subject: [PATCH 23/24] test(web): align canonical approval extension --- e2e/canonical-conversation.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/canonical-conversation.spec.ts b/e2e/canonical-conversation.spec.ts index 53f6489..0bf2ef9 100644 --- a/e2e/canonical-conversation.spec.ts +++ b/e2e/canonical-conversation.spec.ts @@ -218,7 +218,9 @@ test('Hosted UI sends an allowed attachment and selected model only through cano }, ], modelRef: 'fixture-model-alt', - approvalMode: 'risk', + extensions: { + 'ksadk.approval': 'risk', + }, }); expect(input.idempotencyKey).toBe( `conversation:${String(input.inputId).replace(/^input:/, '')}`, From 85f0eba748d997b4393aa7e1f76afb3ea61ca241 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 12:02:15 +0800 Subject: [PATCH 24/24] chore(release): attest final ksadk-web 0.3.3 source --- RELEASE_PROVENANCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index 89e2b90..bdcdf05 100644 --- a/RELEASE_PROVENANCE.json +++ b/RELEASE_PROVENANCE.json @@ -2,6 +2,6 @@ "schema_version": 1, "package": "@kingsoftcloud/ksadk-web", "version": "0.3.3", - "source_commit": "8f6a91b3137c5fcfc9edfc3e85bdc6b1c8289aa5", + "source_commit": "37eb8dd6ae8c44ea4622d39f8c7ae9b13916a793", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" }