Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry

# Conflicts:
#	apps/web/tests/smoke-real.e2e.ts
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.module.css
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
This commit is contained in:
Yichen Jiang
2026-07-27 12:14:23 +08:00
1521 changed files with 44884 additions and 10950 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 80228a180faba0c556ff720e999b29b5bb1635b6
README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819

View File

@@ -1,5 +1,7 @@
# @deepseek-ai/dsh-client-connection
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## Keyless fixture

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-client-connection
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。
## 模型体验
无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **history 的隐式恢复存在争议**:在未附加的会话上打开 history会在主机侧拉起 agent纯持久化读取的替代方案记录在 rt-core 协调账本中P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
- **计划移除 `ToolEventView``ToolCallView``ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时它们会一并移除呈现属于客户端在此之前fixture 保留一份局部 `viewFor` 镜像。

View File

@@ -9,6 +9,7 @@ export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -124,6 +124,49 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
// keyed registration a top-level bash row uses).
{
const turn = 64
const callId = `fx-call-${turn}`
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
+ 'const demo = await tools.read({ path: "notes/demo.txt" })\n'
+ 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n'
+ 'return { listing, demo }'
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}run_code 样本。`), source: { kind: 'user' } } })
push({ type: 'step/start', data: { turn, step: 0 } })
push({
type: 'assistant/message', surfaceOp: 'append',
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
})
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } })
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
push({
type: 'tool/code-dispatch-start',
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
})
push({
type: 'tool/code-dispatch',
data: {
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
},
})
}
dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
push({
type: 'tool/result', surfaceOp: 'append',
data: { turn, step: 0, callId, content: text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), isError: false },
})
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
return events as unknown as SessionEvent[]
}
@@ -304,10 +347,11 @@ class FxInbox<F> implements StreamConn<F> {
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
*/
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// The resident fixture sessions all carry history, so none of them is blank.
const sessions: SessionSummary[] = options.empty ? [] : [
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
]
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
@@ -384,6 +428,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
}
const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id)
/** Shared session guard for sessionId-addressed catalog routes: the error
* response when the session is unknown, undefined when it exists. */
const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<never>> | undefined => {
if (summaryOf(request.payload.sessionId) !== undefined) return undefined
return err<{ sessionId: SessionId }, never>(request, {
code: 'session-not-found',
message: `no session ${request.payload.sessionId}`,
details: { sessionId: request.payload.sessionId },
})
}
const setRunning = (id: SessionId, running: boolean): void => {
const summary = summaryOf(id)
if (summary === undefined || summary.running === running) return
@@ -596,12 +650,13 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
}
}
const created: SessionSummary = {
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd,
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
}
sessions.push(created)
attachedSessions += 1
const emitSession = (): void => {
emitHost({ type: 'host/session-added', sessionId: created.sessionId, cwd })
// Mirrors the host: the frame fires at creation, so blank is constantly true.
emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd })
}
if (workspace !== undefined && options.failWorkspaceAttach) {
emitSession()
@@ -642,6 +697,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
})
}
summary.updatedAt = Date.now()
// First accepted prompt appends events: the summary stops being blank.
summary.blank = false
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (mode === 'steer' && replays.has(id)) {
// Steering: insert a steering message into the current turn; the replay continues.
@@ -752,6 +809,52 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return ok(request, { workspace: { ...workspace } })
},
},
commands: {
// The catalog mirrors one session's effective view (every fixture
// session has an agent, like the real host).
list: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
return ok(request, {
commands: [
{ name: 'compact', description: 'fixture压缩当前会话上下文' },
{ name: 'echo', description: 'fixture回显参数', input: { hint: 'text to echo' } },
{ name: 'goal-fixture', description: 'fixture目标样本命令', input: { hint: '<objective>' } },
],
})
},
execute: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const line = request.payload.line.trim()
const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
const name = match?.[1]
if (name === 'compact' || name === 'echo') {
return ok(request, {
matched: true as const,
result: { kind: 'success' as const, text: name === 'echo' ? (match?.[2] ?? '') : 'fixture已压缩假动作' },
})
}
if (name === 'goal-fixture') {
return ok(request, {
matched: true as const,
result: { kind: 'success' as const, text: `fixturegoal 已设置(${request.payload.sessionId}` },
})
}
return ok(request, { matched: false as const })
},
},
skills: {
list: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
return ok(request, {
skills: [
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
],
})
},
},
events: {
async *mux(_request, signal) {
const conn = new FxInbox<MuxFrame>()
@@ -869,6 +972,10 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'command.list': return this.api.commands.list(request)
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
case 'skill.list': return this.api.skills.list(request)
}
}

View File

@@ -14,6 +14,7 @@ export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -2,7 +2,8 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId,
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
RpcRequest, RpcResponse, SessionId, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -85,6 +86,24 @@ export class FakeApiClient implements IApiClient {
}))),
}
// Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program catalogs and skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly skills: IApiClient['skills'] = {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -0,0 +1,92 @@
/**
* Fixture commands/skills domains: contract-shape conformance for the two
* domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute
* parse/dispatch, skill.list session resolution, and the FixtureApiClient
* dispatch rows.
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
import type { RpcRequest } from '../src/client/api.ts'
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
const sid = (id: string): SessionId => id as SessionId
let reqCount = 0
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload })
const signal = new AbortController().signal
describe('createFixtureApi commands/skills', () => {
it('serves the addressed session catalog with rpcId echo', async () => {
const api = createFixtureApi()
const request = req({ sessionId: sid('fx-alpha') })
const response = await api.commands.list(request)
expect(response.rpcId).toBe(request.rpcId)
if (!response.result.ok) throw new Error('list failed')
const commands = response.result.value.commands
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture'])
// input hint rides only the commands declaring it.
const echo = commands.find(c => c.name === 'echo')
expect(echo?.input?.hint).toBeTruthy()
expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined()
})
it('rejects a catalog request for an unknown session', async () => {
const api = createFixtureApi()
const response = await api.commands.list(req({ sessionId: sid('fx-nope') }))
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
it('executes a known command line and reports matched with a result', async () => {
const api = createFixtureApi()
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
if (!response.result.ok) throw new Error('execute failed')
expect(response.result.value.matched).toBe(true)
expect(response.result.value.result).toEqual({ kind: 'success', text: 'hello world' })
})
it('addresses execute to the session (result text carries the id)', async () => {
const api = createFixtureApi()
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
if (!hit.result.ok) throw new Error('execute failed')
expect(hit.result.value.matched).toBe(true)
expect(hit.result.value.result?.text).toContain('fx-alpha')
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
it('falls to matched:false on unknown names and non-command lines', async () => {
const api = createFixtureApi()
for (const line of ['/nope', 'plain text', '/']) {
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
if (!response.result.ok) throw new Error('execute failed')
expect(response.result.value.matched).toBe(false)
expect(response.result.value.result).toBeUndefined()
}
})
it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => {
const api = createFixtureApi()
const response = await api.skills.list(req({ sessionId: sid('fx-alpha') }))
if (!response.result.ok) throw new Error('skill list failed')
expect(response.result.value.skills[0]?.name).toBe('fixture-demo')
const missingSession = await api.skills.list(req({ sessionId: sid('fx-nope') }))
expect(missingSession.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
})
describe('FixtureApiClient command/skill dispatch', () => {
it('routes the three method keys through the in-memory dispatch table', async () => {
const client = new FixtureApiClient()
const list = await client.commands.list({ sessionId: sid('fx-alpha') })
if (!list.result.ok) throw new Error('command.list failed')
expect(list.result.value.commands.length).toBeGreaterThan(0)
const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' })
if (!executed.result.ok) throw new Error('command.execute failed')
expect(executed.result.value.matched).toBe(true)
const skills = await client.skills.list({ sessionId: sid('fx-alpha') })
if (!skills.result.ok) throw new Error('skill.list failed')
expect(skills.result.value.skills.length).toBeGreaterThan(0)
})
})

View File

@@ -90,7 +90,7 @@ describe('createFixtureApi', () => {
await consuming
if (!created.result.ok) throw new Error('create failed')
const createdId = created.result.value.sessionId
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, cwd: '/tmp/fixture' }])
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }])
const list = await api.sessions.list(req({}))
if (!list.result.ok) throw new Error('list failed')
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
@@ -387,7 +387,7 @@ describe('createFixtureApi', () => {
await consuming
// The session lands with the workspace's path as cwd, and the account
// write pushes the fresh workspace snapshot after session-added.
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, cwd: '/tmp/fixture' })
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' })
expect(seen[1]).toMatchObject({
type: 'host/workspace-changed',
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
@@ -416,7 +416,7 @@ describe('createFixtureApi', () => {
expect(frames[0]).toMatchObject({
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
})
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, cwd: made.result.value.workspace.path })
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path })
const retried = await api.sessions.create(req({
workspaceId: made.result.value.workspace.workspaceId,