Merge remote-tracking branch 'origin/master' into feature/delete-workspace

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
NI0317
2026-07-27 15:54:51 +08:00
174 changed files with 3676 additions and 564 deletions

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: d2a10b3d97837ac859c52c206afab06913ea222e
README.zh.md: f23f8cb184242edbd6d19aeff5823f1efbee8eba
README.md: a434b2d5719de2f30a883ee6e0d26264b3c62f4e
README.zh.md: a79fa99578e6bce4f0ff8e7c1f7538df8a436778

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.
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.
## 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。
客户端 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 时消失。
## Workspace 与 Session 列表

View File

@@ -30,7 +30,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'

View File

@@ -4,11 +4,14 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
@@ -241,4 +244,7 @@ export interface ConversationSnapshot {
*/
blank: boolean
lastAgentError: string | null
/** Current whole-list `todo/write` projection — the tail page's full-log value, then each live
* write (last write wins); empty = the log holds no plan. */
todos: readonly TodoItem[]
}

View File

@@ -2,7 +2,7 @@
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
@@ -99,6 +99,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** Current whole-list todo/write projection: each tail history response replaces it (an omitted
* field is the authoritative empty list) and every live write overwrites it. */
private todos: readonly TodoItem[] = []
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
@@ -479,13 +482,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = result.error
return
}
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
}
this.openState = 'open'
} catch (error) {
@@ -503,11 +506,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
// Session-level projection from the tail page (full-log latest todo/write,
// independent of the window); an in-window write below re-derives the same
// value, and later live events keep overwriting it. Every caller here is a
// tail request (no beforeSeq), which the host answers with the projection
// or omits it only when the full log holds no todo/write — so an absent
// field is the authoritative empty list, not a missing carrier. Assigning
// it clears a plan the log never kept (a write lost to a host crash).
this.todos = todos ?? []
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
const buffered = this.liveBuffer
@@ -558,7 +569,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
@@ -678,6 +689,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
return
}
case 'todo/write': {
this.todos = event.data.todos
return
}
case 'turn/end': {
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
@@ -722,7 +737,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text).
* todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log
* projection, not derivable from an arbitrary window). The window always extends to the log
* tail, so an in-window todo/write can only overwrite it with the same latest value. */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
@@ -792,6 +810,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,
todos: this.todos,
}
}
}

View File

@@ -40,6 +40,8 @@ export const ev = {
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
at(seq, { type: 'todo/write', data: { todos } }),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */

View File

@@ -62,7 +62,7 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[] }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))

View File

@@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
return { api, session: new Session(SID, api) }
}
function histResponse(events: SessionEvent[], hasMore = false) {
function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } }))
}
describe('open', () => {
@@ -153,6 +153,42 @@ describe('live event path', () => {
})
})
it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => {
const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }]
const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }]
const { session } = await opened()
expect(session.getSnapshot().todos).toEqual([])
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.todoWrite(6, listA))
expect(session.getSnapshot().todos).toEqual(listA)
feed(ev.todoWrite(7, listB))
expect(session.getSnapshot().todos).toEqual(listB)
// Window replay converges on the same last snapshot (history contains both writes).
const replayed = makeSession()
replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)])
await replayed.session.open()
expect(replayed.session.getSnapshot().todos).toEqual(listB)
})
it('seeds todos from the tail page projection when the last write precedes the window', async () => {
const list = [{ content: '窗口外的计划', status: 'in_progress' as const }]
// Cold open: the page window carries NO todo/write; the projection rides the response.
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list)
await session.open()
expect(session.getSnapshot().todos).toEqual(list)
// Paging an older window in must not clear the session-level projection.
api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false)
await session.loadOlder()
expect(session.getSnapshot().todos).toEqual(list)
// A later live write still overrides the seeded projection.
session.handleMuxEnvelope('r' as never, {
type: 'session/event', sessionId: SID,
event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]),
})
expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }])
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
@@ -166,6 +202,37 @@ describe('live event path', () => {
const seqs = session.getSnapshot().nodes.map(n => n.seq)
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
})
it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
expect(session.getSnapshot().todos).toEqual([])
// The missed range contained a todo/write that the repulled page no longer
// covers; the response's session-level projection is the only carrier.
const current = [{ content: '断线期间写的', status: 'in_progress' as const }]
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current)
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') })
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})
await Promise.resolve()
expect(session.getSnapshot().todos).toEqual(current)
})
it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => {
// Live write lands, then the host crashes before persisting it: the
// authoritative log holds no todo/write, so the resync tail response
// carries no projection — an omitted field on a tail request is the empty
// list, not a missing carrier, and the rolled-back plan must disappear.
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b'))
session.handleMuxEnvelope('r' as never, {
type: 'session/event', sessionId: SID,
event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]),
})
expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }])
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.resync()
expect(session.getSnapshot().todos).toEqual([])
})
})
describe('paging', () => {