Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a1713a5
fix(conversation): wait for explicit run terminal state
AgentArcLab Aug 31, 2026
b13906c
chore(release): attest ksadk-web 0.3.4 source
AgentArcLab Aug 31, 2026
79c5ffc
test(conversation): emit explicit terminal status
AgentArcLab Aug 31, 2026
7f7e185
chore(release): refresh ksadk-web 0.3.4 provenance
AgentArcLab Aug 31, 2026
207fc64
feat(web): publish interactive demo and thinking shimmer
AgentArcLab Aug 31, 2026
7237e20
chore(release): refresh ksadk-web 0.3.4 provenance
AgentArcLab Aug 31, 2026
4a63ace
feat(demo): exercise approval streaming and feedback
AgentArcLab Aug 31, 2026
da10096
chore(release): refresh ksadk-web 0.3.4 provenance
AgentArcLab Aug 31, 2026
6a08d63
test(release): verify packed conversation admission
AgentArcLab Aug 31, 2026
0215b57
chore(release): refresh ksadk-web 0.3.4 provenance
AgentArcLab Aug 31, 2026
b72417b
fix(stream): preserve runtime item identity in hosted replay
AgentArcLab Aug 31, 2026
b053bb0
chore(release): refresh ksadk-web 0.3.4 provenance
AgentArcLab Aug 31, 2026
905a146
fix(chat): keep restored session history after reload
AgentArcLab Aug 31, 2026
b930184
chore(release): refresh ksadk-web 0.3.4 provenance
AgentArcLab Aug 31, 2026
6f6c502
fix(chat): rebuild history from canonical runtime events
AgentArcLab Aug 31, 2026
e07a98f
chore(release): refresh ksadk-web 0.3.4 provenance
AgentArcLab Aug 31, 2026
f4e2396
fix(chat): keep persisted runtime history in canonical order
AgentArcLab Aug 31, 2026
520c31c
chore(release): refresh ksadk-web 0.3.4 provenance
AgentArcLab Aug 31, 2026
afbce00
fix(stream): reject empty successful responses
AgentArcLab Aug 31, 2026
88412e1
chore(release): refresh ksadk-web 0.3.4 provenance
AgentArcLab Aug 31, 2026
670887a
fix(chat): retain fallback for incomplete runtime replay
AgentArcLab Aug 31, 2026
255972d
fix(conversation): isolate terminal state by run
AgentArcLab Aug 31, 2026
a46a437
chore(release): attest ksadk-web 0.3.4 stream fix
AgentArcLab Aug 31, 2026
5a23f23
fix(stream): separate mirrored legacy answer from reasoning
AgentArcLab Sep 1, 2026
1fbe571
chore(release): attest final 0.3.4 stream candidate
AgentArcLab Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules/
dist/
dist-hosted/
dist-ksadk/
dist-demo/
dist-lib/
coverage/
output/
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions RELEASE_PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -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"
}
38 changes: 38 additions & 0 deletions e2e/demo.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
2 changes: 2 additions & 0 deletions e2e/fixtures/canonical-conversation-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}));
Expand Down Expand Up @@ -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',
}));
Expand Down
187 changes: 182 additions & 5 deletions e2e/reconnect.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading