diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e5765df..f3a9546 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -25,11 +25,11 @@ jobs: - run: npm ci - run: npm test - - run: npm run build:ksadk + - run: npm run build:demo - uses: actions/upload-pages-artifact@v3 with: - path: dist-ksadk + path: dist-demo deploy: environment: diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index d13fe6b..ccd6eeb 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -55,7 +55,7 @@ jobs: - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: - path: dist-ksadk + path: dist-demo deploy-pages: environment: diff --git a/.gitignore b/.gitignore index 38f67ff..6135b48 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ dist-hosted/ dist-ksadk/ +dist-demo/ dist-lib/ coverage/ output/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8520627..2deac80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## 0.3.4 - 2026-08-31 + +- Normalize the real RuntimeEvent/v2 wire shape before presentation: consume + `item.updated.update` as a named content part, retain stable + `run_id / scope_id / item_id / part_id` identity, apply append versus + replace semantics, handle atomic `item.snapshot_replaced`, and settle failed + tool items instead of leaving them visibly running. +- Give each canonical run exactly one transcript owner. Live canonical items + replace the same run's legacy replay projection, while reconnect rebuilds the + complete durable item timeline. Distinct reasoning items, repeated calls to + the same tool, commentary messages, and the final answer no longer collapse + into one row or render twice. +- Rebuild refreshed sessions from the complete persisted RuntimeEvent/v2 log + instead of rendering every cumulative `ListSessionMessages` snapshot as a + separate answer. Canonical runs retain item identity across reload, while + historical runtimes without canonical events continue to use the projected + message API as a compatibility fallback. +- Repair the legacy runtime shape that streamed an answer through the + reasoning channel and repeated it as a terminal snapshot. When no text + delta was received and the exact terminal answer is mirrored only at the + tail of the final thinking block, the renderer removes that run-local mirror + before presenting the authoritative answer; unrelated or repeated text is + never deduplicated by content. +- Keep a conversation turn active until an explicit run-level terminal status + arrives. Completed user messages, tools, approvals, usage reports, and + provider notifications no longer unlock the composer or trigger duplicate + submissions while the agent is still running. +- Make active reasoning visibly animated with a motion-safe text shimmer, + matching WeWork's low-noise thinking treatment across both canonical and + legacy rows while preserving the compact, collapsible completed state. +- Follow new output only while the reader remains near the bottom. Replayed + snapshots and terminal layout changes preserve an intentionally scrolled-up + viewport instead of pulling it back to the latest token. +- Publish a dedicated interactive GitHub Pages demo that exercises the shared + reasoning, tool, approval tray, token-by-token Markdown, feedback, and + composer components entirely in the browser. It is explicitly labelled as + local sample data instead of attempting to connect to a nonexistent Agent + backend. + ## 0.3.3 - 2026-08-28 ### Headless conversation surface diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index bdcdf05..0ff66af 100644 --- a/RELEASE_PROVENANCE.json +++ b/RELEASE_PROVENANCE.json @@ -1,7 +1,7 @@ { "schema_version": 1, "package": "@kingsoftcloud/ksadk-web", - "version": "0.3.3", - "source_commit": "37eb8dd6ae8c44ea4622d39f8c7ae9b13916a793", + "version": "0.3.4", + "source_commit": "5a23f235b31600fb44f4f939de3e60d0e76056df", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" } diff --git a/e2e/demo.spec.ts b/e2e/demo.spec.ts new file mode 100644 index 0000000..dd957d2 --- /dev/null +++ b/e2e/demo.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; + +test('public demo streams reasoning, tool, approval, Markdown, and feedback without a backend', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'KsADK Web 示例 Agent' })).toBeVisible(); + await expect(page.getByText('本地交互演示', { exact: true })).toBeVisible(); + await expect(page.getByText(/不会连接或冒充真实 Agent/)).toBeVisible(); + + const composer = page.getByRole('textbox', { name: '演示消息' }); + const send = page.getByRole('button', { name: '发送演示消息' }); + await composer.fill('验证公开演示'); + await send.click(); + + await expect(page.getByTestId('thinking-indicator')).toHaveText('正在思考…'); + await expect(page.getByTestId('thinking-indicator')).toHaveClass(/waiting-thinking-text/); + const thinkingAnimation = page.getByTestId('thinking-indicator'); + await expect.poll(async () => thinkingAnimation.evaluate((element) => { + const animation = element.getAnimations()[0]; + return typeof animation?.currentTime === 'number' ? animation.currentTime : 0; + })).toBeGreaterThan(100); + await expect(page.getByRole('button', { name: '发送演示消息' })).toBeDisabled(); + await expect(page.getByRole('button', { name: /正在运行 demo\.prepare_summary/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /已完成 demo\.prepare_summary/ })).toBeVisible(); + + const tray = page.getByTestId('interaction-tray'); + await expect(tray).toBeVisible(); + await expect(tray).toContainText('允许继续生成演示结果?'); + await expect(tray).toContainText('批准后将继续流式输出'); + await page.getByTestId('interaction-approve').click(); + + await expect(page.getByText('已批准继续执行。', { exact: true })).toBeVisible(); + await expect(page.getByText('已收到:“验证公开演示”。', { exact: true })).toBeVisible(); + await expect(page.getByText(/按字符增量渲染正文/)).toBeVisible(); + await expect(page.getByText('本次回复有帮助吗?')).toBeVisible(); + await page.getByRole('button', { name: '有帮助' }).click(); + await expect(page.getByRole('button', { name: '删除反馈' })).toBeVisible(); +}); diff --git a/e2e/fixtures/canonical-conversation-server.mjs b/e2e/fixtures/canonical-conversation-server.mjs index c262c48..b434317 100644 --- a/e2e/fixtures/canonical-conversation-server.mjs +++ b/e2e/fixtures/canonical-conversation-server.mjs @@ -359,6 +359,7 @@ async function streamFirstReconnect(response, requestUrl, request) { sourceEventId: 'event-terminal-1', kind: 'progress', schema: 'conversation.item.progress/v1', + payload: { status: 'completed' }, operation: 'completed', lifecycle: 'completed', })); @@ -403,6 +404,7 @@ async function streamSecondTurn(response) { sourceEventId: 'event-terminal-2', kind: 'progress', schema: 'conversation.item.progress/v1', + payload: { status: 'completed' }, operation: 'completed', lifecycle: 'completed', })); diff --git a/e2e/reconnect.spec.mjs b/e2e/reconnect.spec.mjs index a363118..83700da 100644 --- a/e2e/reconnect.spec.mjs +++ b/e2e/reconnect.spec.mjs @@ -77,6 +77,82 @@ function assistantSnapshot(text, seqId) { }; } +function canonicalRuntimeRecord({ + seqId, + eventType, + itemId, + itemKind, + nativeKind, + text = '', + operation = 'replace', +}) { + const runtimeEvent = { + schema_version: 2, + event_id: `canonical-${seqId}`, + seq: seqId, + timestamp: 1788180974 + seqId, + run_id: INVOCATION_ID, + scope_id: `scope-${INVOCATION_ID}`, + source: { + framework: 'codex', + metadata: { native_item_kind: nativeKind }, + }, + event_type: eventType, + item_id: itemId, + item_kind: itemKind, + ...(eventType === 'item.updated' + ? { + op: operation, + update: { + content_type: 'text', + part_id: `${itemId}-part`, + text, + }, + } + : { + snapshot: { + parts: [{ + content_type: 'text', + part_id: `${itemId}-part`, + text, + data: nativeKind === 'userMessage' + ? { type: 'userMessage', content: [{ text }] } + : undefined, + }], + }, + }), + }; + const sessionEvent = { + schema_version: 1, + event_id: `session-${seqId}`, + session_id: SESSION_ID, + seq: seqId, + timestamp: new Date(1788180974000 + seqId * 1000).toISOString(), + family: 'runtime', + family_version: 2, + event_type: eventType, + payload: runtimeEvent, + run_id: INVOCATION_ID, + }; + return { + EventId: `record-${seqId}`, + SessionId: SESSION_ID, + Author: 'codex', + EventType: eventType, + Content: { session_event: sessionEvent, runtime_event: runtimeEvent }, + Metadata: { + ksadk_session_event_envelope: true, + schema_version: 1, + family: 'runtime', + family_version: 2, + canonical_event_id: sessionEvent.event_id, + run_id: INVOCATION_ID, + }, + SeqId: seqId, + Timestamp: sessionEvent.timestamp, + }; +} + async function installReconnectFixture(page, options = {}) { let releaseSubscription; const subscriptionGate = options.holdSubscription @@ -120,8 +196,8 @@ async function installReconnectFixture(page, options = {}) { AgentId: 'fixture-agent', Title: '正在生成的会话', UpdatedAt: updatedAt, - ActiveRunStatus: '', - ActiveInvocationId: INVOCATION_ID, + ActiveRunStatus: options.completedSession ? 'completed' : '', + ActiveInvocationId: options.completedSession ? '' : INVOCATION_ID, }, { SessionId: OTHER_SESSION_ID, @@ -144,15 +220,25 @@ async function installReconnectFixture(page, options = {}) { ? { SessionId: SESSION_ID, AgentId: 'fixture-agent', - ActiveRunStatus: '', - ActiveInvocationId: INVOCATION_ID, + ActiveRunStatus: options.completedSession ? 'completed' : '', + ActiveInvocationId: options.completedSession ? '' : INVOCATION_ID, UpdatedAt: updatedAt, } : { SessionId: OTHER_SESSION_ID, AgentId: 'fixture-agent', ActiveRunStatus: 'completed' }, }; } if (action === 'ListSessionMessages') { - payload = body.SessionId === SESSION_ID + if (options.delayListMessagesMs) { + await new Promise((resolve) => setTimeout(resolve, options.delayListMessagesMs)); + } + payload = body.SessionId === SESSION_ID && options.persistedSnapshots + ? { + Messages: options.persistedSnapshots, + LatestSeqId: options.sessionEvents?.at(-1)?.SeqId ?? options.persistedSnapshots.length, + HasMore: false, + NextCursor: null, + } + : body.SessionId === SESSION_ID ? { Messages: [{ MessageId: 'partial-runtime-message', @@ -177,6 +263,21 @@ async function installReconnectFixture(page, options = {}) { NextCursor: null, }; } + if (action === 'ListSessionEvents') { + const sourceEvents = body.SessionId === SESSION_ID + ? (options.sessionEvents ?? []) + : []; + const offset = Number(body.Offset ?? 0); + const limit = Number(body.Limit ?? (sourceEvents.length || 1)); + const end = Math.max(sourceEvents.length - offset, 0); + const start = Math.max(end - limit, 0); + payload = { + Events: sourceEvents.slice(start, end), + Total: sourceEvents.length, + Offset: offset, + Limit: limit, + }; + } await route.fulfill({ status: 200, contentType: 'application/json', @@ -238,6 +339,82 @@ test('keeps the recovered assistant snapshot visible until the next snapshot arr await expect(page.getByText('连接断开或生成出错,请重试')).toHaveCount(0); }); +test('does not lose restored history when bootstrap state rerenders during a delayed load', async ({ page }) => { + const fixture = await installReconnectFixture(page, { + holdSubscription: true, + delayListMessagesMs: 350, + initialAssistantText: '刷新后仍应恢复的完整历史。', + }); + + await page.goto('/'); + await expect(page.getByText('刷新后仍应恢复的完整历史。', { exact: true })).toBeVisible(); + fixture.releaseSubscription(); +}); + +test('rebuilds persisted canonical history once instead of rendering every cumulative message snapshot', async ({ page }) => { + const sessionEvents = [ + canonicalRuntimeRecord({ + seqId: 1, + eventType: 'item.completed', + itemId: 'user-1', + itemKind: 'message', + nativeKind: 'userMessage', + text: '请继续。', + }), + canonicalRuntimeRecord({ + seqId: 2, + eventType: 'item.updated', + itemId: 'reasoning-1', + itemKind: 'reasoning', + nativeKind: 'reasoning', + text: '唯一思考过程', + operation: 'append', + }), + canonicalRuntimeRecord({ + seqId: 3, + eventType: 'item.completed', + itemId: 'message-1', + itemKind: 'message', + nativeKind: 'agentMessage', + text: '唯一最终答复', + }), + ]; + await installReconnectFixture(page, { + completedSession: true, + sessionEvents, + persistedSnapshots: [ + { + MessageId: 'snapshot-1', + Role: 'assistant', + Content: { text: '唯一最终答复' }, + InvocationId: INVOCATION_ID, + Reasoning: [{ text: '唯一思考过程' }], + SeqId: 2, + }, + { + MessageId: 'snapshot-2', + Role: 'assistant', + Content: { text: '唯一最终答复' }, + InvocationId: INVOCATION_ID, + Reasoning: [{ text: '唯一思考过程' }], + SeqId: 3, + }, + ], + }); + + await page.goto('/'); + + await expect(page.getByText('唯一最终答复', { exact: true })).toHaveCount(1); + await expect(page.getByRole('button', { name: /已思考 · 6 字/ })).toHaveCount(1); + await page.getByRole('button', { name: /已思考 · 6 字/ }).click(); + await expect(page.getByText('唯一思考过程', { exact: true })).toHaveCount(1); + await page.reload(); + await expect(page.getByText('唯一最终答复', { exact: true })).toHaveCount(1); + await expect(page.getByRole('button', { name: /已思考 · 6 字/ })).toHaveCount(1); + await page.getByRole('button', { name: /已思考 · 6 字/ }).click(); + await expect(page.getByText('唯一思考过程', { exact: true })).toHaveCount(1); +}); + test('renders a recovered numbered skill table as GFM instead of raw pipe text', async ({ page }) => { await installReconnectFixture(page); await page.route('**/agentengine/api/v1/SubscribeRunEvents**', async (route) => { diff --git a/package-lock.json b/package-lock.json index f6d88a0..cd749b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.3", + "version": "0.3.4", "license": "Apache-2.0", "dependencies": { "@ag-ui/client": "0.0.57", diff --git a/package.json b/package.json index 8fbb96a..51448e7 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.3", + "version": "0.3.4", "type": "module", "scripts": { "dev": "vite", "build": "npm run build:ksadk", "build:hosted": "VITE_BASE_PATH=/chat/ vite build --outDir dist-hosted", + "build:demo": "VITE_DEMO_MODE=1 VITE_BASE_PATH=./ vite build --outDir dist-demo", "build:lib": "vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json", - "build:all": "npm run build:ksadk && npm run build:hosted && npm run build:lib", + "build:all": "npm run build:ksadk && npm run build:hosted && npm run build:demo && npm run build:lib", "prepack": "npm run build:lib && npm run build:ksadk", "lint": "eslint .", "preview": "vite preview", @@ -16,6 +17,7 @@ "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:demo": "playwright test --config playwright.demo.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.demo.config.mjs b/playwright.demo.config.mjs new file mode 100644 index 0000000..b26f91d --- /dev/null +++ b/playwright.demo.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: 'demo.spec.ts', + workers: 1, + timeout: 20_000, + expect: { timeout: 10_000 }, + use: { + baseURL: 'http://127.0.0.1:4178', + viewport: { width: 1280, height: 900 }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + webServer: { + command: 'VITE_DEMO_MODE=1 npm run dev -- --host 127.0.0.1 --port 4178 --strictPort', + url: 'http://127.0.0.1:4178', + reuseExistingServer: false, + }, +}); diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index a0dbb62..69c6aee 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -15,6 +15,7 @@ export const RELEASE_COMMANDS = Object.freeze([ ['npm', ['run', 'lint']], ['npm', ['run', 'build:all']], ['npm', ['run', 'test:e2e:conversation']], + ['npm', ['run', 'test:e2e:demo']], ]); function run(command, args, options = {}) { diff --git a/scripts/verify-packed-conversation.mjs b/scripts/verify-packed-conversation.mjs index cdb929e..559386d 100644 --- a/scripts/verify-packed-conversation.mjs +++ b/scripts/verify-packed-conversation.mjs @@ -6,6 +6,7 @@ import { decodeConversationInput, decodeConversationItem, decodeConversationSurface, + preflightConversationInput, projectConversationItems, } from '@kingsoftcloud/ksadk-web/conversation'; @@ -18,6 +19,10 @@ const input = buildConversationInput({ idempotencyKey: 'packed-turn-1', parts: [{ kind: 'text', text: 'hello from packed consumer' }], modelRef: 'packed-model', + extensions: { + 'ksadk.approval': 'risk', + 'ksadk.collaboration': 'default', + }, }); assert.deepEqual(decodeConversationInput(input), input); @@ -30,10 +35,12 @@ const surface = decodeConversationSurface({ inputs: [ { name: 'text', mode: 'native' }, { name: 'model.select', mode: 'native' }, + { name: 'approval', mode: 'native' }, ], outputs: [{ name: 'text', mode: 'native' }], }); assert.equal(surface?.apiVersion, 'conversation.ksadk.io/v1'); +assert.deepEqual(preflightConversationInput(surface, input), input); const item = decodeConversationItem({ apiVersion: 'conversation.ksadk.io/v1', diff --git a/src/App.tsx b/src/App.tsx index 19b282e..21b9dd4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -254,9 +254,8 @@ export function AgentWorkbench({ apiAdapter, initialSurface = 'chat', routeShell useEffect(() => { agentIdRef.current = agentId; - currentSessionIdRef.current = currentSessionId; writePersistedSessionId(agentId, currentSessionId); - }, [agentId, currentSessionId, agentIdRef, currentSessionIdRef]); + }, [agentId, currentSessionId, agentIdRef]); useEffect(() => { if (!isMobile) { diff --git a/src/__tests__/chat-message-list-contract.test.ts b/src/__tests__/chat-message-list-contract.test.ts index 72d897c..e1a52f9 100644 --- a/src/__tests__/chat-message-list-contract.test.ts +++ b/src/__tests__/chat-message-list-contract.test.ts @@ -103,10 +103,22 @@ describe('chat message list contracts', () => { expect(source).toContain('max-h-[min(46vh,28rem)]'); expect(source).toContain('custom-scrollbar'); expect(source).toContain('border-slate-200/80'); - expect(source).toContain('生成中'); + expect(source).toContain('正在思考…'); expect(source).toContain('leading-7'); }); + it('uses the same non-spinning shimmer for legacy reasoning rows', () => { + const source = readFileSync(resolve(repoRoot, 'src/components/chat/ChatMessageList.tsx'), 'utf8'); + + expect(source).toContain("className={cn('truncate', reasoningStreaming && 'waiting-thinking-text')}"); + expect(source).toContain("reasoningStreaming ? '正在思考…' : '思考过程'"); + const reasoningSection = source.slice( + source.indexOf('{message.reasoning ? ('), + source.indexOf('{message.tools', source.indexOf('{message.reasoning ? (')), + ); + expect(reasoningSection).not.toContain('animate-spin'); + }); + it('remeasures virtual rows when expandable content changes height', () => { const source = readFileSync(resolve(repoRoot, 'src/components/chat/ChatMessageList.tsx'), 'utf8'); @@ -164,24 +176,22 @@ describe('chat message list contracts', () => { expect(listSource).toContain('取消运行并保留最近 checkpoint'); }); - it('uses projected message cursors without duplicating raw event history loads', () => { + it('uses canonical event history as the transcript owner with projected messages as fallback', () => { const lifecycleSource = readFileSync(resolve(repoRoot, 'src/hooks/useSessionLifecycle.ts'), 'utf8'); expect(lifecycleSource).toContain('loadOlderSessionMessages'); expect(lifecycleSource).toContain('beforeSeqId: historyState.nextCursor'); expect(lifecycleSource).toContain('SESSION_MESSAGES_PAGE_SIZE'); - // Raw event history must not be loaded for the transcript. The only - // allowed listSessionEvents call is the Interaction/v1 pending - // replay (0.3.2), which ingests durable interaction facts only and - // never issues a submit. + // Canonical RuntimeEvent/v2 replay owns new-run transcripts. The durable + // message projection is retained only for legacy runs and pagination. const listSessionEventsCalls = lifecycleSource .split('\n') .filter((line) => line.includes('api.listSessionEvents(sessionId')); - expect(listSessionEventsCalls).toHaveLength(1); - expect(listSessionEventsCalls[0]).toContain('limit: 50'); - expect( - lifecycleSource.indexOf('api.listSessionEvents(sessionId'), - ).toBeGreaterThan(lifecycleSource.indexOf('Interaction/v1')); + expect(listSessionEventsCalls).toHaveLength(0); + expect(lifecycleSource).toContain('loadCompleteSessionEventHistory('); + expect(lifecycleSource).toContain('rebuildPersistedSessionHistory('); + expect(lifecycleSource).toContain('SESSION_EVENTS_PAGE_SIZE = 500'); + expect(lifecycleSource).toContain('canonicalRunIdsBySessionRef'); expect(lifecycleSource).toContain("console.warn('[SessionLifecycle] checkpoint load failed:'"); expect(lifecycleSource).toContain("console.warn('[SessionLifecycle] tool receipt load failed:'"); }); @@ -231,6 +241,11 @@ describe('chat message list contracts', () => { const dispatcherSource = readFileSync(resolve(repoRoot, 'src/core/run/dispatcher.ts'), 'utf8'); expect(appSource).not.toContain('void loadSession(sessionId);'); + // The lifecycle hook owns this imperative ref. Mirroring a stale React + // render back into it can invalidate the in-flight restored-history load: + // the header keeps the selected session while the transcript stays empty + // until the user clicks the same session again. + expect(appSource).not.toContain('currentSessionIdRef.current = currentSessionId;'); expect(appSource).toContain('clearSessionMessageHistory(sessionId)'); expect(lifecycleSource).toContain('const isStillCurrentSession = () => ('); expect(lifecycleSource).toContain('loadSessionGenerationRef.current === generation'); diff --git a/src/__tests__/conversation-client.test.ts b/src/__tests__/conversation-client.test.ts index a185e6d..21e6731 100644 --- a/src/__tests__/conversation-client.test.ts +++ b/src/__tests__/conversation-client.test.ts @@ -165,7 +165,7 @@ describe('HttpConversationClient', () => { operation: 'completed', lifecycle: 'completed', payloadSchemaRef: 'conversation.item.progress/v1', - payload: {}, + payload: { status: 'completed' }, })), ].join('')); } @@ -206,6 +206,48 @@ describe('HttpConversationClient', () => { expect(result.presentation.terminalStatus).toBe('completed'); }); + it('does not treat a completed progress item as a completed run', async () => { + const calls: string[] = []; + const completedNotification = item('source-notification', '', { + itemId: 'notification-1', + kind: 'progress', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.progress/v1', + payload: { message: 'autoApprovalReview' }, + }); + const fetcher = vi.fn(async (url: string) => { + calls.push(url); + if (url.includes('conversation:stream')) { + return stream(frame(1, 'item.completed', completedNotification)); + } + return stream(frame(2, 'run.completed', item('source-terminal', '', { + itemId: 'run-end', + kind: 'progress', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.progress/v1', + payload: { status: 'completed' }, + }))); + }); + const client = new HttpConversationClient({ + fetch: fetcher, + maxReconnects: 1, + sleep: async () => {}, + }); + + const result = await client.streamTurn({ + bootstrap: { buildId: 'build-1', surface: SURFACE }, + input: input(), + }); + + expect(calls).toEqual([ + '/api/v1/builds/build-1/conversation:stream', + '/api/v1/runs/run-1/events?after=1', + ]); + 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', diff --git a/src/__tests__/conversation-protocol.test.ts b/src/__tests__/conversation-protocol.test.ts index fc97817..5ace386 100644 --- a/src/__tests__/conversation-protocol.test.ts +++ b/src/__tests__/conversation-protocol.test.ts @@ -163,6 +163,42 @@ describe('ConversationItem/v1 identity reducer', () => { }); describe('ConversationItem/v1 renderer projection', () => { + it('only derives run terminal state from an explicit run status', () => { + let state = createConversationItemState(); + state = reduceConversationItem(state, decodedItem({ + itemId: 'notification-1', + sourceEventIds: ['notification-event'], + kind: 'progress', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.progress/v1', + payload: { message: 'autoApprovalReview' }, + })); + expect(projectConversationItems(state).terminalStatus).toBeUndefined(); + + state = reduceConversationItem(state, decodedItem({ + itemId: 'tool-error', + sourceEventIds: ['tool-error-event'], + kind: 'error', + operation: 'completed', + lifecycle: 'failed', + payloadSchemaRef: 'conversation.item.error/v1', + payload: { error: 'one tool failed' }, + })); + expect(projectConversationItems(state).terminalStatus).toBeUndefined(); + + state = reduceConversationItem(state, decodedItem({ + itemId: 'run-end', + sourceEventIds: ['run-end-event'], + kind: 'progress', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.progress/v1', + payload: { status: 'completed' }, + })); + expect(projectConversationItems(state).terminalStatus).toBe('completed'); + }); + it('keeps stream order and enriches a tool call with a separate result item', () => { let state = createConversationItemState(); state = reduceConversationItem(state, decodedItem({ diff --git a/src/__tests__/demo-page-contract.test.ts b/src/__tests__/demo-page-contract.test.ts new file mode 100644 index 0000000..cbf38c0 --- /dev/null +++ b/src/__tests__/demo-page-contract.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +describe('public demo page contract', () => { + it('builds a dedicated interactive demo instead of bootstrapping a missing Agent', () => { + const main = readFileSync(resolve(repoRoot, 'src/main.tsx'), 'utf8'); + const demo = readFileSync(resolve(repoRoot, 'src/demo/DemoWorkbench.tsx'), 'utf8'); + const pages = readFileSync(resolve(repoRoot, '.github/workflows/pages.yml'), 'utf8'); + + expect(main).toContain("import.meta.env.VITE_DEMO_MODE === '1'"); + expect(demo).toContain('本地交互演示'); + expect(demo).toContain('不会连接或冒充真实 Agent'); + expect(demo).toContain('setMessages'); + expect(demo).toContain('status: \'streaming\''); + expect(demo).toContain(' { + const source = readFileSync( + resolve(repoRoot, 'src/components/chat/ProcessingBlocksView.tsx'), + 'utf8', + ); + + const styles = readFileSync(resolve(repoRoot, 'src/index.css'), 'utf8'); + expect(source).toContain("generating && 'waiting-thinking-text'"); + expect(source).toContain("data-testid={generating ? 'thinking-indicator'"); + expect(source).toContain("generating ? '正在思考…' : '已思考'"); + expect(styles).toContain('@keyframes waiting-thinking-text'); + expect(styles).toContain('animation: waiting-thinking-text 1.6s linear infinite'); + expect(styles).toContain('@media (prefers-reduced-motion: reduce)'); + }); +}); diff --git a/src/__tests__/hosted-conversation.test.ts b/src/__tests__/hosted-conversation.test.ts index d26d9f1..25e2e62 100644 --- a/src/__tests__/hosted-conversation.test.ts +++ b/src/__tests__/hosted-conversation.test.ts @@ -63,6 +63,57 @@ function stream(body: string): Response { } describe('Hosted UI canonical ConversationItem projection', () => { + it('keeps the visible fallback when an interim canonical snapshot has no renderable item', async () => { + const client = new HttpConversationClient({ + fetch: vi.fn(async () => stream([ + frame(1, item( + 'progress-only', + 'progress-event', + 'progress', + 'conversation.item.progress/v1', + { status: 'running' }, + )), + frame(2, item( + 'progress-terminal', + 'progress-terminal-event', + 'progress', + 'conversation.item.progress/v1', + { status: 'completed' }, + { operation: 'completed', lifecycle: 'completed' }, + )), + ].join(''))), + maxReconnects: 0, + }); + useSessionStore.getState().setCurrentSessionId('session-hosted'); + useMessageStore.getState().setMessages([{ + id: 'legacy-preview', + role: 'model', + content: 'foreground preview remains visible', + timestamp: 1, + invocationId: 'run-hosted', + }]); + + await client.streamTurn({ + bootstrap: { buildId: 'build-hosted', surface: SURFACE }, + input: buildConversationInput({ + inputId: 'input-progress-only', + sessionId: 'session-hosted', + idempotencyKey: 'turn-progress-only', + parts: [{ kind: 'text', text: 'hello' }], + }), + onUpdate: (snapshot) => dispatchRunEventToStores({ + type: 'conversation_snapshot', + result: snapshot, + sessionId: 'session-hosted', + }), + }); + + expect(useMessageStore.getState().messages).toContainEqual(expect.objectContaining({ + id: 'legacy-preview', + content: 'foreground preview remains visible', + })); + }); + it('uses one identity reducer across reconnect for text, reasoning, tool, approval and A2UI', async () => { const operations = [{ version: 'v0.9', @@ -153,7 +204,7 @@ describe('Hosted UI canonical ConversationItem projection', () => { 'event-9', 'progress', 'conversation.item.progress/v1', - {}, + { status: 'completed' }, { operation: 'completed', lifecycle: 'completed' }, )), ].join('')); @@ -249,7 +300,7 @@ describe('Hosted UI canonical ConversationItem projection', () => { 'terminal-event-readonly', 'progress', 'conversation.item.progress/v1', - {}, + { status: 'completed' }, { operation: 'completed', lifecycle: 'completed' }, )), ].join(''))), diff --git a/src/__tests__/kernel-events.test.ts b/src/__tests__/kernel-events.test.ts index e30be3a..ba5b33b 100644 --- a/src/__tests__/kernel-events.test.ts +++ b/src/__tests__/kernel-events.test.ts @@ -59,15 +59,77 @@ describe('KernelRunEventTranslator', () => { it('accumulates item.updated append deltas into cumulative snapshots', () => { const t = new KernelRunEventTranslator('sess-1'); const first = t.translate( - itemFrame(11, 'item.updated', 'agentMessage', { op: 'replace', ...AGENT_SNAPSHOT('你好') }), + itemFrame(11, 'item.updated', 'agentMessage', { + item_id: 'message-1', + op: 'replace', + update: { part_id: 'p2', content_type: 'text', text: '你好' }, + }), ); const second = t.translate( - itemFrame(12, 'item.updated', 'agentMessage', { op: 'append', ...AGENT_SNAPSHOT(',世界') }), + itemFrame(12, 'item.updated', 'agentMessage', { + item_id: 'message-1', + op: 'append', + update: { part_id: 'p2', content_type: 'text', text: ',世界' }, + }), ); expect(first?.Content).toEqual({ parts: [{ text: '你好' }] }); expect(second?.Content).toEqual({ parts: [{ text: '你好,世界' }] }); }); + it('reads item.updated from the RuntimeEvent/v2 update field', () => { + const t = new KernelRunEventTranslator('sess-1'); + const first = t.translate(itemFrame(15, 'item.updated', 'agentMessage', { + item_id: 'message-1', + op: 'append', + update: { part_id: 'p2', content_type: 'text', text: '云端' }, + })); + const second = t.translate(itemFrame(16, 'item.updated', 'agentMessage', { + item_id: 'message-1', + op: 'append', + update: { part_id: 'p2', content_type: 'text', text: '流式' }, + })); + + expect(first?.Content).toEqual({ parts: [{ text: '云端' }] }); + expect(second?.Content).toEqual({ parts: [{ text: '云端流式' }] }); + }); + + it('atomically replaces an open assistant item snapshot', () => { + const t = new KernelRunEventTranslator('sess-1'); + t.translate(itemFrame(17, 'item.updated', 'agentMessage', { + item_id: 'message-1', + op: 'append', + update: { part_id: 'p2', content_type: 'text', text: '旧内容' }, + })); + const replaced = t.translate(itemFrame(18, 'item.snapshot_replaced', 'agentMessage', { + item_id: 'message-1', + snapshot: { parts: [{ part_id: 'p2', content_type: 'text', text: '修正后的内容' }] }, + })); + + expect(replaced?.EventType).toBe('assistant_stream_snapshot'); + expect(replaced?.Content).toEqual({ parts: [{ text: '修正后的内容' }] }); + expect(replaced?.Metadata?.RuntimeItem).toMatchObject({ + ItemId: 'message-1', + PartId: 'p2', + Operation: 'replace', + }); + }); + + it('atomically replaces an open reasoning item snapshot', () => { + const t = new KernelRunEventTranslator('sess-1'); + const replaced = t.translate(itemFrame(19, 'item.snapshot_replaced', 'reasoning', { + item_id: 'reasoning-1', + snapshot: { parts: [{ part_id: 'rp1', content_type: 'text', text: '新的推理摘要' }] }, + })); + + expect(replaced?.EventType).toBe('reasoning'); + expect(replaced?.Content).toEqual({ parts: [{ text: '新的推理摘要' }] }); + expect(replaced?.Metadata?.RuntimeItem).toMatchObject({ + ItemId: 'reasoning-1', + PartId: 'rp1', + Operation: 'replace', + }); + }); + it('translates interaction frames verbatim for the interaction adapter', () => { const t = new KernelRunEventTranslator('sess-1'); const record = t.translate({ @@ -112,8 +174,34 @@ describe('KernelRunEventTranslator', () => { ); expect(started?.EventType).toBe('tool_call'); expect(started?.Metadata?.call_id).toBe('tool-1'); + expect(started?.Metadata?.RuntimeItem).toMatchObject({ + ItemId: 'tool-1', + Operation: 'replace', + }); expect(completed?.EventType).toBe('tool_result'); expect(completed?.Metadata?.call_id).toBe('tool-1'); + expect(completed?.Metadata?.RuntimeItem).toMatchObject({ + ItemId: 'tool-1', + Operation: 'completed', + }); + }); + + it('settles a failed tool item as an identity-bound error result', () => { + const t = new KernelRunEventTranslator('sess-1'); + const failed = t.translate(itemFrame(20, 'item.failed', 'commandExecution', { + item_id: 'tool-1', + error: { code: 'command_failed', message: 'permission denied' }, + })); + + expect(failed?.EventType).toBe('tool_result'); + expect(failed?.Metadata?.call_id).toBe('tool-1'); + expect(failed?.Metadata?.tool_output).toEqual({ + error: { code: 'command_failed', message: 'permission denied' }, + }); + expect(failed?.Metadata?.RuntimeItem).toMatchObject({ + ItemId: 'tool-1', + Operation: 'completed', + }); }); it('skips control noise and non-runtime families', () => { diff --git a/src/__tests__/persisted-session-history.test.ts b/src/__tests__/persisted-session-history.test.ts new file mode 100644 index 0000000..c12d554 --- /dev/null +++ b/src/__tests__/persisted-session-history.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { rebuildPersistedSessionHistory } from '../utils/persisted-session-history.js'; +import type { Message } from '../components/chat/types.js'; +import type { PersistedSessionEventRecord } from '../utils/persisted-session-history.js'; + +describe('rebuildPersistedSessionHistory', () => { + it('normalises RuntimeEvent Unix seconds before ordering with millisecond message rows', () => { + const fallback: Message[] = [{ + id: 'user-1', + role: 'user', + content: '先提问', + timestamp: 1_700_000_000_000, + }]; + const events: PersistedSessionEventRecord[] = [{ + SeqId: 1, + EventId: 'event-1', + EventType: 'runtime.item.completed', + InvocationId: 'run-1', + // Production RuntimeEvent/v2 persists this value in Unix seconds. + Timestamp: 1_700_000_001 as unknown as string, + Content: { + runtime_event: { + family: 'runtime', + event_type: 'item.completed', + event_id: 'event-1', + run_id: 'run-1', + scope_id: 'run-1', + item_id: 'assistant-1', + item_kind: 'message', + snapshot: { parts: [{ part_id: 'text-1', text: '再回答' }] }, + source: { metadata: { native_item_kind: 'agentMessage' } }, + }, + }, + }]; + + const rebuilt = rebuildPersistedSessionHistory(fallback, events, 'session-1'); + + expect(rebuilt.messages.map((message) => message.content)).toEqual(['先提问', '再回答']); + expect(rebuilt.messages[1]?.timestamp).toBe(1_700_000_001_000); + }); + + it('keeps the compatibility projection when canonical history contains only a user item', () => { + const fallback: Message[] = [ + { + id: 'user-fallback', + role: 'user', + content: '继续上文', + timestamp: 1_700_000_000_000, + invocationId: 'run-incomplete', + }, + { + id: 'assistant-fallback', + role: 'model', + content: '这是仍可从兼容投影读取的回复。', + timestamp: 1_700_000_001_000, + invocationId: 'run-incomplete', + }, + ]; + const events: PersistedSessionEventRecord[] = [{ + SeqId: 1, + EventId: 'event-user-only', + EventType: 'runtime.item.completed', + InvocationId: 'run-incomplete', + Timestamp: 1_700_000_000 as unknown as string, + Content: { + runtime_event: { + family: 'runtime', + event_type: 'item.completed', + event_id: 'event-user-only', + run_id: 'run-incomplete', + scope_id: 'run-incomplete', + item_id: 'user-item', + item_kind: 'message', + snapshot: { + parts: [{ + part_id: 'user-part', + content_type: 'data', + data: { + type: 'userMessage', + content: [{ type: 'text', text: '继续上文' }], + }, + }], + }, + source: { metadata: { native_item_kind: 'userMessage' } }, + }, + }, + }]; + + const rebuilt = rebuildPersistedSessionHistory(fallback, events, 'session-1'); + + expect(rebuilt.messages.map((message) => message.content)).toEqual([ + '继续上文', + '这是仍可从兼容投影读取的回复。', + ]); + expect(rebuilt.canonicalRunIds).toEqual([]); + }); +}); diff --git a/src/__tests__/recovered-run.test.ts b/src/__tests__/recovered-run.test.ts index e07dddf..bdf18d1 100644 --- a/src/__tests__/recovered-run.test.ts +++ b/src/__tests__/recovered-run.test.ts @@ -28,4 +28,163 @@ describe('mergeRecoveredRunMessages', () => { content: '这是一段已经恢复的完整回复。', }); }); + + it('keeps distinct runtime message items and replaces completed reasoning snapshots', () => { + const runtimeItem = ( + itemId: string, + operation: 'append' | 'replace' | 'completed', + partId = 'text-0', + ) => ({ + RuntimeItem: { + RunId: 'run-1', + ScopeId: 'scope-1', + ItemId: itemId, + PartId: partId, + Operation: operation, + }, + }); + const events = [ + { + EventId: 'reason-delta', + EventType: 'reasoning', + InvocationId: 'run-1', + Content: { parts: [{ text: '先分析' }] }, + Metadata: runtimeItem('reason-1', 'append', 'reason-0'), + }, + { + EventId: 'reason-completed', + EventType: 'reasoning', + InvocationId: 'run-1', + Content: { parts: [{ text: '先分析,再核对' }] }, + Metadata: runtimeItem('reason-1', 'completed', 'reason-0'), + }, + { + EventId: 'commentary-completed', + EventType: 'assistant_message', + InvocationId: 'run-1', + Content: { parts: [{ text: '我先检查目录。' }] }, + Metadata: runtimeItem('message-1', 'completed'), + }, + { + EventId: 'answer-completed', + EventType: 'assistant_message', + InvocationId: 'run-1', + Content: { parts: [{ text: '最终答案。' }] }, + Metadata: runtimeItem('message-2', 'completed'), + }, + ]; + + const merged = mergeRecoveredRunMessages([], events, 'run-1'); + + expect(merged).toHaveLength(2); + expect(merged.map((message) => message.itemId)).toEqual(['message-1', 'message-2']); + expect(merged[0]).toMatchObject({ + content: '我先检查目录。', + reasoning: '先分析,再核对', + }); + expect(merged[1]).toMatchObject({ content: '最终答案。' }); + }); + + it('streams a later runtime item after an earlier item in the same run completed', () => { + const metadata = (itemId: string, operation: 'replace' | 'completed') => ({ + RuntimeItem: { + RunId: 'run-1', + ScopeId: 'scope-1', + ItemId: itemId, + PartId: 'text-0', + Operation: operation, + }, + }); + const merged = mergeRecoveredRunMessages([], [ + { + EventId: 'commentary-completed', + EventType: 'assistant_message', + InvocationId: 'run-1', + Content: { parts: [{ text: '正在检查。' }] }, + Metadata: metadata('message-1', 'completed'), + }, + { + EventId: 'answer-snapshot', + EventType: 'assistant_stream_snapshot', + InvocationId: 'run-1', + Content: { parts: [{ text: '最终答案的前半段' }] }, + Metadata: metadata('message-2', 'replace'), + }, + ], 'run-1'); + + expect(merged.map((message) => message.content)).toEqual([ + '正在检查。', + '最终答案的前半段', + ]); + }); + + it('keeps repeated tool names as distinct runtime items across replay', () => { + const metadata = (itemId: string, operation: 'replace' | 'completed') => ({ + call_id: itemId, + tool_name: 'search', + RuntimeItem: { + RunId: 'run-1', + ScopeId: 'scope-1', + ItemId: itemId, + Operation: operation, + }, + }); + const events = [ + { + EventId: 'tool-1-start', + EventType: 'tool_call', + InvocationId: 'run-1', + Metadata: { ...metadata('tool-1', 'replace'), tool_args: { q: 'first' } }, + }, + { + EventId: 'tool-1-done', + EventType: 'tool_result', + InvocationId: 'run-1', + Metadata: { ...metadata('tool-1', 'completed'), tool_output: { value: 'one' } }, + }, + { + EventId: 'tool-2-start', + EventType: 'tool_call', + InvocationId: 'run-1', + Metadata: { ...metadata('tool-2', 'replace'), tool_args: { q: 'second' } }, + }, + { + EventId: 'tool-2-done', + EventType: 'tool_result', + InvocationId: 'run-1', + Metadata: { ...metadata('tool-2', 'completed'), tool_output: { value: 'two' } }, + }, + ]; + + const merged = mergeRecoveredRunMessages([], events, 'run-1'); + + expect(merged.map((message) => message.itemId)).toEqual(['tool-1', 'tool-2']); + expect(merged.map((message) => message.tools?.search?.output)).toEqual([ + '{\n "value": "one"\n}', + '{\n "value": "two"\n}', + ]); + }); + + it('keeps consecutive reasoning items separate by RuntimeItem identity', () => { + const events = ['reason-1', 'reason-2'].map((itemId, index) => ({ + EventId: `${itemId}-done`, + EventType: 'reasoning', + InvocationId: 'run-1', + Content: { parts: [{ text: index === 0 ? '第一段推理' : '第二段推理' }] }, + Metadata: { + RuntimeItem: { + RunId: 'run-1', + ScopeId: 'scope-1', + ItemId: itemId, + PartId: 'reason-0', + Operation: 'completed', + }, + }, + })); + + const merged = mergeRecoveredRunMessages([], events, 'run-1'); + + expect(merged.map((message) => message.itemId)).toEqual(['reason-1', 'reason-2']); + expect(merged.map((message) => message.reasoning)).toEqual(['第一段推理', '第二段推理']); + }); }); diff --git a/src/__tests__/run-engine.test.ts b/src/__tests__/run-engine.test.ts index a4b99a6..abfc939 100644 --- a/src/__tests__/run-engine.test.ts +++ b/src/__tests__/run-engine.test.ts @@ -143,7 +143,7 @@ function canonicalResult(): ConversationStreamResult { lifecycle: 'completed', visibility: 'public', payloadSchemaRef: 'conversation.item.progress/v1', - payload: {}, + payload: { status: 'completed' }, nativeRef: {}, }, ]; @@ -224,6 +224,51 @@ describe('RunEngineImpl', () => { })); }); + it('keeps one transcript owner when canonical and durable run projections overlap', () => { + useSessionStore.getState().setCurrentSessionId('session-canonical'); + const result = canonicalResult(); + + dispatchRunEventToStores({ + type: 'stream_event', + sessionId: 'session-canonical', + event: { + EventId: 'legacy-final-before-canonical', + EventType: 'assistant_message', + SessionId: 'session-canonical', + InvocationId: 'run-canonical', + SeqId: 10, + Content: { parts: [{ text: 'canonical answer' }] }, + }, + }); + dispatchRunEventToStores({ + type: 'conversation_snapshot', + sessionId: 'session-canonical', + result, + }); + dispatchRunEventToStores({ + type: 'stream_event', + sessionId: 'session-canonical', + event: { + EventId: 'legacy-final-after-canonical', + EventType: 'assistant_message', + SessionId: 'session-canonical', + InvocationId: 'run-canonical', + SeqId: 11, + Content: { parts: [{ text: 'canonical answer' }] }, + }, + }); + + const answers = useMessageStore.getState().messages.filter( + (message) => message.role === 'model' && message.content === 'canonical answer', + ); + expect(answers).toHaveLength(1); + expect(answers[0]).toMatchObject({ + eventType: 'conversation_item_v1', + runId: 'run-canonical', + itemId: 'canonical-answer', + }); + }); + it('keeps the existing Responses path only when the conversation surface endpoint is absent', async () => { const calls: Record[] = []; const conversationClient: ConversationClient = { @@ -1054,6 +1099,27 @@ describe('RunEngineImpl', () => { }); }); + it('removes only a terminal answer mirrored at the tail of legacy reasoning', () => { + dispatchRunEventToStores({ + type: 'reasoning_delta', + messageId: 'assistant-legacy', + delta: 'I will summarize. 最终答案', + }); + dispatchRunEventToStores({ + type: 'text_final', + messageId: 'assistant-legacy', + text: '最终答案', + }); + + const message = useMessageStore.getState().messages[0]; + expect(message.reasoning).toBe('I will summarize. '); + expect(message.content).toBe('最终答案'); + expect(message.blocks).toEqual([ + expect.objectContaining({ type: 'thinking', content: 'I will summarize. ', status: 'done' }), + expect.objectContaining({ type: 'text', content: '最终答案', status: 'done' }), + ]); + }); + it('settles an offscreen session when its Responses stream ends after a session switch', () => { useSessionStore.getState().setCurrentSessionId('session-visible'); useStreamingStore.getState().setSessionStreaming('session-background', true); @@ -1076,6 +1142,42 @@ describe('RunEngineImpl', () => { }); }); + it('only finalizes streaming blocks owned by the completed canonical run', () => { + useSessionStore.getState().setCurrentSessionId('session-live'); + useMessageStore.getState().setMessages([ + { + id: 'assistant-run-a', + role: 'model', + content: '', + timestamp: 1, + runId: 'run-a', + blocks: [ + { id: 'thinking-a', type: 'thinking', content: 'run a is thinking', status: 'streaming' }, + ], + }, + { + id: 'assistant-run-b', + role: 'model', + content: '', + timestamp: 2, + runId: 'run-b', + blocks: [ + { id: 'thinking-b', type: 'thinking', content: 'run b is thinking', status: 'streaming' }, + ], + }, + ]); + + dispatchRunEventToStores({ + type: 'stream_ended', + sessionId: 'session-live', + runId: 'run-a', + }); + + const [runA, runB] = useMessageStore.getState().messages; + expect(runA.blocks?.[0]?.status).toBe('done'); + expect(runB.blocks?.[0]?.status).toBe('streaming'); + }); + it('settles running tools when a run reaches a terminal status', () => { useSessionStore.getState().setCurrentSessionId('session-live'); dispatchRunEventToStores({ @@ -1290,6 +1392,10 @@ describe('RunEngineImpl', () => { }, }); const settledSessionIds: Array = []; + const events: Array<{ type: string; error?: Error }> = []; + engine.subscribe((event) => { + events.push(event); + }); engine.updateConfig({ agentId: 'agent-live', @@ -1317,6 +1423,8 @@ describe('RunEngineImpl', () => { expect(calls[0]).toMatchObject({ SessionId: 'session-history' }); expect(createdSessions).toEqual([]); expect(settledSessionIds).toEqual(['session-history']); + expect(events.some((event) => event.type === 'activity' && 'phase' in event && event.phase === '运行完成')).toBe(false); + expect(events.find((event) => event.type === 'error')?.error?.message).toContain('空响应流'); }); it('does not create a second session or replay a prompt after an empty first stream', async () => { @@ -1338,6 +1446,10 @@ describe('RunEngineImpl', () => { }); }, }); + const events: Array<{ type: string; error?: Error }> = []; + engine.subscribe((event) => { + events.push(event); + }); engine.updateConfig({ agentId: 'agent-live', @@ -1354,6 +1466,8 @@ describe('RunEngineImpl', () => { expect(createdSessions).toEqual(['session-1']); expect(calls).toHaveLength(1); expect(calls[0]).toMatchObject({ SessionId: 'session-1' }); + expect(events.some((event) => event.type === 'activity' && 'phase' in event && event.phase === '运行完成')).toBe(false); + expect(events.find((event) => event.type === 'error')?.error?.message).toContain('空响应流'); }); it('keeps response.failed as failed instead of overwriting it as completed', async () => { diff --git a/src/__tests__/session-event-history.test.ts b/src/__tests__/session-event-history.test.ts index 76a8685..0fa946a 100644 --- a/src/__tests__/session-event-history.test.ts +++ b/src/__tests__/session-event-history.test.ts @@ -42,7 +42,6 @@ describe('session event history loading', () => { ); expect(result?.total).toBe(306); expect(calls).toEqual([ - { offset: 0, limit: 1 }, { offset: 0, limit: 50 }, { offset: 50, limit: 50 }, { offset: 100, limit: 50 }, @@ -75,12 +74,12 @@ describe('session event history loading', () => { }, { pageSize: 50, - shouldContinue: () => calls < 2, + shouldContinue: () => calls < 1, }, ); expect(result).toBeNull(); - expect(calls).toBe(2); + expect(calls).toBe(1); }); it('requests the next older page by skipping already loaded latest events', () => { diff --git a/src/components/chat/ChatMessageList.tsx b/src/components/chat/ChatMessageList.tsx index af83aa9..94e6d2d 100644 --- a/src/components/chat/ChatMessageList.tsx +++ b/src/components/chat/ChatMessageList.tsx @@ -618,18 +618,21 @@ function ToolPayloadBlock({ ); } -function FeedbackControls({ +export function FeedbackControls({ isLastMessage, isStreaming, message, onDeleteFeedback, onSubmitFeedback, + alwaysVisible = false, }: { isLastMessage: boolean; isStreaming: boolean; message: Message; onDeleteFeedback: (message: Message) => void; onSubmitFeedback: ChatMessageListProps['onSubmitFeedback']; + /** Public demo and embedded review surfaces may keep the feedback controls visible. */ + alwaysVisible?: boolean; }) { const [commentOpen, setCommentOpen] = useState(false); const [comment, setComment] = useState(message.feedback?.comment || ''); @@ -652,7 +655,12 @@ function FeedbackControls({ }; return ( -
+
+
+ + + +
+
+
+ +
+

KsADK Web 示例 Agent

+

思考 · 工具 · 审批 · 流式正文 · 反馈

+
+
+ 本地交互演示 +
+ +
+
+
+
无需后端即可体验完整时间线
+

这是公开站点的示例数据,不会连接或冒充真实 Agent。生产接入请使用下方源码与文档。

+
+ + {messages.map((message, index) => message.role === 'user' ? ( +
+
+
+ +
+
+ ) : ( +
+
示例 Agent
+ + {feedbackMessageId === message.id ? ( + updateFeedback(target, rating, comment)} + /> + ) : null} +
+ ))} +
+
+ +
+ {pendingInteraction ? ( + undefined} + onRespond={respondToInteraction} + /> + ) : null} +
+