Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml
#	docs/core-data-structures/session.i18n.yaml
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/headless-agent/tests/todo-write.e2e.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/todo/tool-todo/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-07-28 20:41:55 +08:00
391 changed files with 6129 additions and 3065 deletions

View File

@@ -5,8 +5,24 @@
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
// approval/question requests exercise replay and composer takeover with stable rpcIds.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
import {
createAssistantMessage,
createToolResultMessage,
createUserMessage,
} from '@deepseek-ai/dsh-llm/message'
import { CallId } from '@deepseek-ai/dsh-llm/brand'
import type {
AssistantMessage,
ContentBlock,
MessageSource,
ToolResultMessage,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import type {
SessionEvent,
SessionId,
TodoItem,
} from '@deepseek-ai/dsh-session/types'
// Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
@@ -27,6 +43,21 @@ function text(t: string): ContentBlock[] {
return [{ type: 'text', text: t }]
}
function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'user' }): UserMessage {
return createUserMessage({ content, source })
}
function assistantMessage(content: ContentBlock[]): AssistantMessage {
return createAssistantMessage({
content,
source: { provider: 'fixture', model: 'fx-1' },
})
}
function toolResultMessage(callId: string, content: ContentBlock[], isError: boolean): ToolResultMessage {
return createToolResultMessage({ callId: CallId(callId), content, isError })
}
const MARKDOWN_FIXTURE = [
'# Markdown fixture',
'',
@@ -86,10 +117,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
const userSeq = push({
type: 'user/message', surfaceOp: 'append',
data: {
content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`),
source: { kind: 'user' },
},
data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`)),
})
if (turn === 0) {
push({
@@ -98,7 +126,7 @@ function buildAlphaLog(): SessionEvent[] {
})
}
if (turn % 9 === 4) {
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`[fixture] 上下文注入turn ${turn}`), { kind: 'plugin', plugin: 'fixture' }) })
}
push({ type: 'step/start', data: { turn, step: 0 } })
const withTool = turn % 5 === 2
@@ -109,19 +137,19 @@ function buildAlphaLog(): SessionEvent[] {
if (withTool) {
const callId = `fx-call-${turn}`
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(`ECHO: TURN ${turn}`), isError: turn % 25 === 12 } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(`ECHO: TURN ${turn}`), turn % 25 === 12) } })
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'step/start', data: { turn, step: 1 } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, content: text(`工具结果已消化turn ${turn})。`), provenance: { provider: 'fixture', model: 'fx-1' } } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, message: assistantMessage(text(`工具结果已消化turn ${turn})。`)) } })
push({ type: 'step/end', data: { turn, step: 1 } })
} else {
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
push({ type: 'step/end', data: { turn, step: 0 } })
}
if (turn % 13 === 6) {
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, content: text(`插话 ${turn}fixture steering 消息。`), source: { kind: 'user' } } })
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(text(`插话 ${turn}fixture steering 消息。`)) } })
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
@@ -131,14 +159,14 @@ function buildAlphaLog(): SessionEvent[] {
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
const callId = `fx-call-${turn}`
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}${name} 样本。`), source: { kind: 'user' } } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}${name} 样本。`)) })
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, arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) },
})
push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(resultText), isError: false } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } })
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
@@ -159,11 +187,11 @@ function buildAlphaLog(): SessionEvent[] {
+ '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: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}run_code 样本。`)) })
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' } },
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock]) },
})
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 => {
@@ -184,7 +212,7 @@ function buildAlphaLog(): SessionEvent[] {
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 },
data: { turn, step: 0, message: toolResultMessage(callId, text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), false) },
})
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
@@ -261,13 +289,13 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const callId = String(event.data.callId)
const callId = String(event.data.message.source.callId)
for (let i = log.length - 1; i >= 0; i--) {
const candidate = log[i]
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
so the undefined arm needs a sparse log no code path builds. */
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
const resultText = event.data.content.map(b => (b.type === 'text' ? b.text : '')).join('')
const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
return view === undefined ? undefined : { for: 'result', view }
}
@@ -284,20 +312,31 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
if (titleEvent !== undefined) {
values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title
}
const todos = backscanTodos(log)
if (todos !== undefined) values['todos'] = todos
// Always present (tool-todo unit composed): null when no plan stands.
values['todos'] = backscanTodos(log) ?? null
return values
}
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
const type = (event as { type: string }).type
const key = type === 'session/title' ? 'title' : type === 'todo/write' ? 'todos' : undefined
if (key === undefined) return []
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing event is in the log, so its key always has a value. */
if (!Object.hasOwn(values, key)) return []
return [{ type: 'session/projection', sessionId: id, key, value: values[key], seq: event.seq }]
if (type === 'session/title') {
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
if (!Object.hasOwn(values, 'title')) return []
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
}
// Standing-plan fold: writes replace the list; turn/start clears it (null).
if (type === 'todo/write' || type === 'turn/start') {
return [{
type: 'session/projection',
sessionId: id,
key: 'todos',
value: backscanTodos(log) ?? null,
seq: event.seq,
}]
}
return []
}
/**
@@ -331,11 +370,16 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */
/**
* Current plan projection over the full log (host parallel: latest todo/write
* with no later turn/start; a new turn retires the previous plan).
*/
function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined {
for (let i = log.length - 1; i >= 0; i--) {
const event = log[i]
if (event !== undefined && event.type === 'todo/write') return event.data.todos
if (event === undefined) continue
if (event.type === 'turn/start') return undefined
if (event.type === 'todo/write') return event.data.todos
}
return undefined
}
@@ -555,7 +599,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
/** Log append + mux emit (the normal live path). */
appendUser(id: string, msg: string): void {
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: userMessage(text(msg)) })
},
/** Append a later durable title revision through the normal raw-event + control-frame path. */
appendTitle(id: string, title: string): void {
@@ -566,7 +610,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
appendSilent(id: string, msg: string): void {
const log = logOf(sid(id))
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: { content: text(msg), source: { kind: 'user' } } } as unknown as SessionEvent)
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent)
},
/** End every open stream generator (client sees both streams close -> reconnect + resync path). */
breakStreams(): void {
@@ -587,7 +631,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
replays.delete(id)
const done = pieces.slice(0, i).join('')
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(aborted ? `${done}(已中断)` : done), provenance: { provider: 'fixture', model: 'fx-1' } } })
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: assistantMessage(text(aborted ? `${done}(已中断)` : done)) } })
append(id, { type: 'step/end', data: { turn, step } })
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
setRunning(id, false)
@@ -756,14 +800,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// Steering: insert a steering message into the current turn; the replay continues.
/* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
const turn = (nextTurn.get(id) ?? 1) - 1
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content, source: { kind: 'user' } } })
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(content) } })
return ok(request, { accepted: true as const })
}
const turn = nextTurn.get(id) ?? 0
nextTurn.set(id, turn + 1)
setRunning(id, true)
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
startReply(
id,
turn,

View File

@@ -69,7 +69,10 @@ describe('createFixtureApi', () => {
// tail block still rides it — empty-log cut at -1, the host convention.
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
if (!empty.result.ok) throw new Error('empty failed')
expect(empty.result.value).toEqual({ events: [], hasMore: false, projections: { asOfSeq: -1, values: {} } })
// Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write.
expect(empty.result.value).toEqual({
events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } },
})
})
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {

View File

@@ -2,5 +2,5 @@
# 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 packages/client/runtime/README.md
README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
## Workspace 与 Session 列表

View File

@@ -57,21 +57,23 @@ function materializeNode(
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.content, source: event.data.source,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: String(event.data.callId),
callId,
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
callTime: call?.time ?? null,
content: event.data.content, isError: event.data.isError,
content: result.content, isError: result.isError === true,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
callView: call?.callView ?? null,

View File

@@ -360,13 +360,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return
}
case 'session/queued': {
const message = frame.message
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
// provisional-echo reconciliation key); otherwise the frame envelope id.
const key = 'rpcId' in frame.source ? String(frame.source.rpcId) : `f:${rpcId}`
const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}`
this.queued.push({
row: { key, preview: queuePreviewOf(frame.content) },
row: { key, preview: queuePreviewOf(message.content) },
steering: frame.steering,
sourceJson: JSON.stringify(frame.source),
sourceJson: JSON.stringify(message.source),
})
this.queueRev++
this.notifier.markDirty()
@@ -603,7 +604,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (event.data.trigger.kind !== 'message') return
index = this.queued.findIndex(entry => !entry.steering)
} else if (event.type === 'steering/message') {
const source = JSON.stringify(event.data.source)
const source = JSON.stringify(event.data.message.source)
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
} else {
return
@@ -701,7 +702,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return
}
case 'tool/result': {
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++
return
}
case 'turn/end': {

View File

@@ -1,3 +1,4 @@
import { createUserMessage, createMessage, createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm'
// Minimal SessionEvent builders for orchestration tests (shape mirrors what the
// host emits; only the fields the object layer reads).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
@@ -13,7 +14,9 @@ export const ev = {
turnStart: (seq: number, turn: number): SessionEvent =>
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
user: (seq: number, body: string): SessionEvent =>
at(seq, { type: 'user/message', surfaceOp: 'append', data: { content: text(body), source: { kind: 'user' } } }),
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: text(body), source: { kind: 'user' },
}) }),
stepStart: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/start', data: { turn, step } }),
chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent =>
@@ -21,11 +24,33 @@ export const ev = {
chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent =>
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(body), provenance: { provider: 'fake', model: 'fk-1' } } }),
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: {
turn, step,
message: createMessage({
role: 'assistant',
content: text(body),
source: {
kind: 'model',
...{ provider: 'fake', model: 'fk-1' },
},
}),
} }),
toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
at(seq, {
type: 'tool/result',
surfaceOp: 'append',
data: {
turn,
step,
message: createToolResultMessage({
callId: CallId(callId),
content: text(body),
isError: false,
}),
},
}),
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch-start',

View File

@@ -1,3 +1,4 @@
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
/**
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
* windows, incremental append with node-cache identity, six-variant
@@ -39,8 +40,16 @@ describe('FoldAdapter', () => {
const events = [
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
turn: 0,
message: createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
}),
} }),
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]
@@ -76,7 +85,17 @@ describe('FoldAdapter', () => {
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
const window = [
ev.user(10, '正常'),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0, step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: {
kind: 'model',
...{ provider: 'x', model: 'y' },
},
}),
} }),
]
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
@@ -98,7 +117,15 @@ describe('FoldAdapter', () => {
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
adapter.reset([
at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
turn: 0, step: 0,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: true,
}),
error: { name: 'Boom', code: 'boom' },
} }),
], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
@@ -203,7 +230,15 @@ describe('FoldAdapter', () => {
adapter.reset([
ev.commandRun(0, 'cmd-5', 'plan'),
ev.commandDone(1, 'cmd-5'),
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0,
step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: { kind: 'model', provider: 'x', model: 'y' },
}),
} }),
], 0)
const { nodes, degraded } = adapter.nodes()
expect(degraded).toBe(true)

View File

@@ -5,6 +5,7 @@
* pre-instantiation buffering, and snapshot reference stability.
*/
import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
@@ -19,8 +20,12 @@ const rid = (id: string): RpcId => id as RpcId
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
return {
type: 'session/queued', sessionId: SID, content: text(body),
source: { kind: 'user', rpcId: rid(rpcId) } as never,
type: 'session/queued',
sessionId: SID,
message: createUserMessage({
content: text(body),
source: { kind: 'user', rpcId: rid(rpcId) } as never,
}),
steering,
}
}
@@ -40,9 +45,12 @@ describe('queue intake', () => {
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-2'), {
type: 'session/queued', sessionId: SID,
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
source: { kind: 'plugin', plugin: 'loop' },
type: 'session/queued',
sessionId: SID,
message: createUserMessage({
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
source: { kind: 'plugin', plugin: 'loop' },
}),
steering: false,
})
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
@@ -93,14 +101,26 @@ describe('queue retirement (host queuedMirror rules)', () => {
const foreignSteering = {
seq: 0, time: 1,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } },
data: {
turn: 0,
message: createUserMessage({
content: text('loop'),
source: { kind: 'plugin', plugin: 'loop' },
}),
},
} as never
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering })
expect(session.getSnapshot().queue).toHaveLength(2)
const matchedSteering = {
seq: 1, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } },
data: {
turn: 0,
message: createUserMessage({
content: text('插话'),
source: { kind: 'user', rpcId: rid('p-2') },
}),
},
} as never
session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
@@ -154,7 +174,13 @@ describe('queue reconnect semantics', () => {
const committed = {
seq: 6, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } },
data: {
turn: 1,
message: createUserMessage({
content: text('重连插话'),
source: { kind: 'user', rpcId: rid('p-steer') },
}),
},
} as never
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
expect(session.getSnapshot().queue).toEqual([])

View File

@@ -2,5 +2,5 @@
# 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 packages/client/ui-conversation/README.md
README.md: 6cb4952eff2a98f8294c68ab96693a396ae32c5f
README.zh.md: 563fbae996bf19822f8b600489a6168e9c736600
README.md: da799e7e8d922f659e9a7a7babcca19d98c7c211
README.zh.md: 35b932bb6b45283c123730366543ee59acd5fecc

View File

@@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` plus a `+<n>` count of the other active ones in its own non-shrinking span, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). Several items may be `in_progress` at once (the tool permits parallel work), so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` plus a `+<n>` count of the other active ones in its own non-shrinking span, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). Several items may be `in_progress` at once (the tool permits parallel work), so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.

View File

@@ -12,7 +12,7 @@
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 经 `toolviews/plan-summary.ts``planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,并把「其余活跃项的数量」`+<n>` 放在自己的不收缩 span 里;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。可以有多个条目同时处于 `in_progress`(工具允许并行工作),因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 经 `toolviews/plan-summary.ts``planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,并把「其余活跃项的数量」`+<n>` 放在自己的不收缩 span 里;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。可以有多个条目同时处于 `in_progress`(工具允许并行工作),因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是计划条`useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。

View File

@@ -1,9 +1,9 @@
// TodoPanel: persistent plan strip above the composer (the web counterpart
// of the TUI plan panel). Renders the latest todo/write whole-list snapshot
// no data of its own, hidden while the list is empty. Mounted through the
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
// the selecting, so the panel takes the plain list and stays framework-free.
// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
// TodoPanel: plan strip above the composer (the web counterpart of the TUI
// plan panel). Renders the standing todo/write whole-list snapshot (cleared on
// the next turn/start) — no data of its own, hidden while the list is empty.
// Mounted through the 'conversation.input.dock' slot (QueueDock posture): the
// dock adapter does the selecting, so the panel takes the plain list and stays
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
import { useId, useState } from 'react'
import type { Context } from 'cordis'

View File

@@ -55,7 +55,11 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
const user = (seq: number, text: string): UserMessageNode => ({
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
kind: 'user',
seq,
time: seq * 1000,
content: [{ type: 'text', text }] as never,
source: null,
})
const assistant = (seq: number, text: string): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],