Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/README.md
#	packages/README.zh.md
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/tests/fake-api.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/src/client/contract/session.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/fake-api.ts
#	packages/client/test-runtime/src/sessions.ts
#	packages/client/test-runtime/tests/runtime.spec.tsx
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/queue-dock.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/rpc-map.ts
#	packages/host/apiproxy/src/api/rpc.schema.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/fetch/client.ts
#	packages/host/apiproxy/src/fetch/handler.ts
#	packages/host/apiproxy/tests/api-proxy-commands.spec.ts
#	packages/host/apiproxy/tests/client-handler.spec.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
This commit is contained in:
creatixchu
2026-07-30 13:48:33 +08:00
207 changed files with 2901 additions and 900 deletions
@@ -29,9 +29,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const TODOS: TodoItem[] = [
@@ -177,7 +177,7 @@ describe('small branch tails', () => {
expect(view.getByText('one-liner')).toBeTruthy()
})
it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => {
it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -199,6 +199,17 @@ describe('small branch tails', () => {
expect(writeText).toHaveBeenCalledWith('answer body')
settled.unmount()
const thinkOnly = render(
<AssistantMarkdown
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
streaming={false}
time={time}
/>,
)
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
expect(thinkOnly.queryByText('14:24')).toBeNull()
thinkOnly.unmount()
const streaming = render(
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
)
@@ -216,6 +227,6 @@ describe('small branch tails', () => {
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
)
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
})
})
@@ -23,9 +23,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'
@@ -12,7 +12,7 @@ import type {
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
afterEach(cleanup)
@@ -51,7 +51,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
describe('deriveStats', () => {
it('folds turns/steps/tokens and cache hit percentage', () => {
it('folds turns/steps/token split and cache hit percentage', () => {
const stats = deriveStats([
assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
@@ -59,19 +59,53 @@ describe('deriveStats', () => {
])
expect(stats.turns).toBe(2)
expect(stats.steps).toBe(3)
expect(stats.tokens).toBe(1200)
expect(stats.inputTokens).toBe(1100)
expect(stats.outputTokens).toBe(100)
expect(stats.cacheHitPct).toBe(82)
})
it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null,
}
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
expect(stats.toolMs).toBe(0)
expect(stats.cacheHitPct).toBeNull()
})
it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => {
const timed: AssistantMessageNode = {
...assistant(1, 1),
timing: { stepStartTime: 1_000, firstTokenTime: 1_200, completedTime: 3_500 },
}
const untimed: AssistantMessageNode = {
...assistant(2, 1),
timing: { stepStartTime: null, firstTokenTime: null, completedTime: 9_000 },
}
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
isError: false, callView: null, resultView: null,
}
const stats = deriveStats([timed, untimed, tool])
expect(stats.llmMs).toBe(2_500)
expect(stats.toolMs).toBe(3_000)
})
})
describe('formatters', () => {
it('formats token counts compactly', () => {
expect(formatTokens(517)).toBe('517')
expect(formatTokens(12_240)).toBe('12.2K')
expect(formatTokens(517_000)).toBe('517K')
expect(formatTokens(1_230_000)).toBe('1.2M')
})
it('formats durations under and over a minute', () => {
expect(formatDuration(45_230)).toBe('45.2s')
expect(formatDuration(162_000)).toBe('2m42s')
})
})
describe('StatsLine', () => {
@@ -79,12 +113,13 @@ describe('StatsLine', () => {
return { useSession: bindSnapshotSelector(source) }
}
it('renders the joined stats row and hides with zero steps', () => {
it('renders the grouped stats row and hides with zero steps', () => {
const { source } = makeSource({
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
})
const view = render(<StatsLine {...props(source)} />)
expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy()
// No timing on the fixture: the duration group drops out whole.
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
const empty = makeSource()
const emptyView = render(<StatsLine {...props(empty.source)} />)
expect(emptyView.container.textContent).toBe('')
@@ -21,10 +21,21 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
const SID = 's1' as SessionId
afterEach(cleanup)
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
@@ -49,7 +49,7 @@ describe('render branch tails', () => {
const view = render(
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok')
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
@@ -1,20 +1,27 @@
// @vitest-environment jsdom
/**
* QueueDock rendering (web input-triggers queue cut 1): empty queue renders
* nothing, rows render one preview line each keyed by rpcId, and the strip
* follows queue changes through the useSession selector.
* QueueDock rendering and operations: authoritative rows, inline editing,
* removal, failure notices, and live retirement.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { QueueItemId } from '../src/client/contract/queue.ts'
import type { InputState } from '../src/client/input/contract.ts'
import { QueueDock, queueDockEntry } from '../src/client/queue/QueueDock.tsx'
import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
const iid = (id: string): QueueItemId => id as QueueItemId
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
return { id: iid(id), preview, text }
}
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
@@ -24,23 +31,23 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
}
}
/** Minimal live source backing the useSession stub (queue swaps notify subscribers). */
/** Minimal live source backing the useSession stub. */
function liveSession(initial: ConversationSnapshot) {
let snapshot = initial
const listeners = new Set<() => void>()
const useSession: SnapshotSelectorHook<ConversationSnapshot> = sel =>
const useSession: SnapshotSelectorHook<ConversationSnapshot> = selector =>
useSyncExternalStore(
(fn) => {
listeners.add(fn)
return () => listeners.delete(fn)
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => sel(snapshot),
() => selector(snapshot),
)
return {
useSession,
push(next: ConversationSnapshot): void {
snapshot = next
for (const fn of [...listeners]) fn()
for (const listener of [...listeners]) listener()
},
}
}
@@ -48,7 +55,7 @@ function liveSession(initial: ConversationSnapshot) {
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
function kitFor(snapshot: ConversationSnapshot) {
function kitFor(snapshot: ConversationSnapshot, injected: Partial<QueueDockInjected> = {}) {
return {
sessionId: SID,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
@@ -58,6 +65,9 @@ function kitFor(snapshot: ConversationSnapshot) {
inputActions: { setDraft: () => {}, submit: () => {} } as never,
session: snapshot,
input: INPUT_STATE,
updateQueue: vi.fn(() => Promise.resolve()),
notify: vi.fn(),
...injected,
}
}
@@ -69,20 +79,118 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('renders one preview row per queued message with the count strip', () => {
it('renders active actions and disables editing for mixed-content rows', () => {
const snap = snapshotWith([
{ key: 'p-1', preview: '第一条排队消息' },
{ key: 'p-2', preview: 'second queued line' },
row('i-1', '第一条排队消息'),
row('i-2', null, 'image [image]'),
])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('已排队 2 条')
const rows = [...container.querySelectorAll('li')]
expect(rows.map(r => r.textContent)).toEqual(['第一条排队消息', 'second queued line'])
expect([...container.querySelectorAll('li')].map(item => item.textContent))
.toEqual(['第一条排队消息', 'image [image]'])
expect(container.querySelectorAll('button')).toHaveLength(4)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
.toBe('包含非文本内容,暂不支持编辑')
})
it('follows queue changes: retirement empties the strip back to null', () => {
const snap = snapshotWith([{ key: 'p-1', preview: '在场' }])
it('edits text inline with save and cancel controls, then saves with the same item identity', async () => {
const snap = snapshotWith([row('i-edit', 'before')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText, queryByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
const editor = getByLabelText('编辑排队消息') as HTMLInputElement
expect(getByLabelText('保存排队消息')).toBeTruthy()
expect(getByLabelText('取消编辑')).toBeTruthy()
expect(queryByLabelText('删除排队消息')).toBeNull()
fireEvent.change(editor, { target: { value: 'after' } })
fireEvent.keyDown(editor, { key: 'Enter' })
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), {
kind: 'edit',
content: [{ type: 'text', text: 'after' }],
})
})
})
it('cancels an edit by button or Escape without mutating the queue', () => {
const snap = snapshotWith([row('i-edit', 'before')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } })
fireEvent.click(getByLabelText('取消编辑'))
expect(getByText('before')).toBeTruthy()
fireEvent.click(getByLabelText('编辑排队消息'))
fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' })
expect(getByText('before')).toBeTruthy()
expect(updateQueue).not.toHaveBeenCalled()
})
it('keeps editing during IME composition and disables a blank save', () => {
const snap = snapshotWith([row('i-edit', 'before')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
const editor = getByLabelText('编辑排队消息')
fireEvent.change(editor, { target: { value: ' ' } })
expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true)
fireEvent.change(editor, { target: { value: '输入中' } })
fireEvent.keyDown(editor, { key: 'Enter', isComposing: true })
expect(updateQueue).not.toHaveBeenCalled()
expect(getByLabelText('编辑排队消息')).toBeTruthy()
})
it('removes the addressed row', async () => {
const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getAllByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
})
})
it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
const snap = snapshotWith([row('i-race', 'pending')])
const source = liveSession(snap)
const notify = vi.fn()
const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('删除排队消息'))
await waitFor(() => {
expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。')
})
expect(getByText('pending')).toBeTruthy()
})
it('follows authoritative retirement back to null', () => {
const snap = snapshotWith([row('i-1', '在场')])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('在场')
@@ -90,11 +198,9 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('ships the registrant plugin shape (list entry into conversation.input.dock)', () => {
// Registration itself runs under T5's slot declaration; here we pin the
// frozen registration surface so the wiring layer can mount it verbatim.
it('ships the session-scoped registrant plugin shape', () => {
expect(queueDockEntry.name).toBe('conversation-queue-dock')
expect(queueDockEntry.inject).toEqual(['slots', 'conversation'])
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
expect(typeof queueDockEntry.apply).toBe('function')
})
})
@@ -14,11 +14,12 @@ import { ConversationService } from '../src/client/service.ts'
async function bench(readAttachment?: SessionFace['readAttachment']) {
const runtime = await SlotTestRuntime.create()
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const loadOlder = vi.fn(() => Promise.resolve())
await runtime.sessions.add({
id: 's1',
session: { prompt, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) },
session: { prompt, updateQueue, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) },
})
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
@@ -27,16 +28,18 @@ async function bench(readAttachment?: SessionFace['readAttachment']) {
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
return { runtime, fiber, hub, root, scoped, prompt, cancel, loadOlder }
return { runtime, fiber, hub, root, scoped, prompt, updateQueue, cancel, loadOlder }
}
describe('ConversationService', () => {
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
await b.scoped.cancel()
await b.scoped.loadOlder()
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
await b.runtime.dispose()
@@ -28,8 +28,21 @@ function fakeWiring() {
return { wiring: shell, sink, shell }
}
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId