Merge origin/master into worktree/web-plugin-config

Three seams: the tsconfig path map gained a mapping on each side and keeps
both; the event-producer matrix is generated, so it was regenerated rather
than hand-merged row by row.
This commit is contained in:
Yichen Jiang
2026-08-11 18:27:53 +08:00
1460 changed files with 20030 additions and 19474 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: b1893dc7c55353eb29e6cccfd84b11d9873060b8
README.zh.md: f5cc65742cf165e61e6a691d616c1cc7984cc9f4
README.md: 2626931bbe6f9c8722c384eb6c321ffdcba943f3
README.zh.md: 94dcc9030dc983e5e0914e8945aaa33d209b90e1

View File

@@ -26,12 +26,16 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
`SessionListState.tasksBySession` mirrors the Host's `session/tasks` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.

View File

@@ -26,12 +26,16 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline被留下的列表会变成幽灵`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit``SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering中途引导不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果claim 竞态则会返回 `queue-item-not-found`

View File

@@ -18,7 +18,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
// Store contract types are ui-slots authority; re-exported beside the engine
// so store consumers get one import surface.
// so store consumers get one import path.
export type {
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -165,7 +165,7 @@ function deepFreeze(value: unknown): void {
/** A live engine instance: the contract instance plus the raw engine store. */
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
/** The underlying engine store (framework/test surface; components never see it). */
/** The underlying engine store (framework/test API; components never see it). */
readonly store: SnapshotStore<T>
}

View File

@@ -53,7 +53,7 @@ export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {
@@ -243,7 +243,7 @@ export function apply(ctx: Context): void {
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches, the settings
// and model surfaces) subscribe on ctx.
// and model services) subscribe on ctx.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
else if (frame.type === 'host/session-preset-changed') {

View File

@@ -324,8 +324,9 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
* - `engaging`: a first prompt was attempted, but no accepted turn or other
* authoritative activity signal has arrived — the UI keeps the composer
* visible through admission and error frames.
* - `active`: the session is non-blank beyond its pending first prompt, is
* running, or owns a pending interaction — the ordinary conversation view.
* - `active`: the session is non-blank beyond its pending first prompt,
* contains visible non-command Chat content, is running, or owns a pending
* interaction — the ordinary conversation view.
*
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; returning to the hero would discard the error context).

View File

@@ -4,7 +4,7 @@
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -48,6 +48,8 @@ export interface SessionListSnapshot {
phase: SessionListPhase
error: RpcError | null
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Background tasks per session; an absent key is an empty set. */
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
currentAddress: SubagentAddress | undefined
}
@@ -138,6 +140,11 @@ export class SessionManager {
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
/**
* Background tasks per session, last-wins from `session/tasks`. An empty set
* is stored as an absent key, so absence and `[]` are one representation.
*/
private readonly tasksBySession = new Map<SessionId, readonly TaskView[]>()
private selected: SessionId | undefined
@@ -423,7 +430,7 @@ export class SessionManager {
}
}
// ---- List surface ----
// ---- List API ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
refreshList(): Promise<void> {
@@ -622,7 +629,7 @@ export class SessionManager {
this.notifier.markDirty()
}
// ---- Subscription surface (for useSessionList) ----
// ---- Subscription API (for useSessionList) ----
/**
* uSES subscription entry for useSessionList.
@@ -682,10 +689,23 @@ export class SessionManager {
this.notifier.markDirty()
return
}
if (frame.type === 'session/tasks') {
// Whole-set snapshot, so last-wins with no reconciliation. The Host omits
// the baseline for an empty set, which is the same fact an emptying change
// reports as `[]` — both land as an absent key.
if (frame.tasks.length === 0) this.tasksBySession.delete(frame.sessionId)
else this.tasksBySession.set(frame.sessionId, frame.tasks)
this.notifier.markDirty()
return
}
if (frame.type === 'session/subscribed') {
// Rows past the host's durable baseline rode state a restart lost; drop
// them so last-wins cannot pin a phantom value over recomputed truth.
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
// Same re-baseline reasoning as the queue below: this generation sends a
// task baseline only when the set is non-empty, so a mirror kept from the
// previous generation would survive as a phantom list.
this.tasksBySession.delete(frame.sessionId)
this.notifier.markDirty()
// New mux-generation baseline: discard the previous queue snapshot.
// The host omits session/queue when the live queue is empty, so retaining
@@ -804,6 +824,11 @@ export class SessionManager {
}
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
// Owner disposal already dropped these registry-side, but that lands on
// the mux stream while this frame rides the host stream, so the two have
// no relative order. Clearing here makes a detached Activation's rows
// disappear whichever arrives first.
this.tasksBySession.delete(frame.sessionId)
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
@@ -1040,6 +1065,7 @@ export class SessionManager {
phase: this.listPhase,
error: this.listError,
subagentsByParent: Object.fromEntries(this.catalogs),
tasksBySession: Object.fromEntries(this.tasksBySession),
currentAddress: current === undefined ? undefined : this.addresses.get(current),
}
}

View File

@@ -16,7 +16,7 @@
*/
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type {
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -86,6 +86,12 @@ export interface SessionListState {
phase: SessionListPhase
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/**
* Background tasks each session can see, mirrored last-wins from
* `session/tasks`. A missing key is an empty set — the Host sends no baseline
* for a session without tasks — so consumers read absence, never a sentinel.
*/
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
/** Current session's catalog-derived address, absent on ordinary navigation. */
currentAddress: SubagentAddress | undefined
}
@@ -291,7 +297,7 @@ export class SessionsService implements ISessions {
)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
@@ -649,7 +655,7 @@ export class SessionsService implements ISessions {
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const {
items, current, phase, subagentsByParent, currentAddress,
items, current, phase, subagentsByParent, tasksBySession, currentAddress,
} = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
@@ -719,7 +725,7 @@ export class SessionsService implements ISessions {
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
})
}
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress })
this.pruneScopes()
}

View File

@@ -60,7 +60,7 @@ export interface SessionOptions {
* remaining public members are manager/runtime entry points.
*/
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
// ---- Window and derived state (all private; the snapshot is the only read API) ----
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
@@ -428,7 +428,7 @@ export class Session implements SessionFace {
await this.open()
}
// ---- Subscription surface (useSyncExternalStore direct wiring) ----
// ---- Subscription API (useSyncExternalStore direct wiring) ----
/**
* uSES subscription entry.
@@ -741,7 +741,8 @@ export class Session implements SessionFace {
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
(!this.blankBit && !this.firstPromptPendingTurn)
hasVisibleConversationContent(chat)
|| (!this.blankBit && !this.firstPromptPendingTurn)
|| this.running
|| this.pendingCache.value.length > 0,
this.promptAttempted,
@@ -774,13 +775,18 @@ function conversationInput(entry: HistoryEntry): ConversationEventInput {
return { event: entry.event, view: entry.view }
}
/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */
function hasVisibleConversationContent(chat: ChatSnapshot): boolean {
return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command')
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). A failed first prompt
* stays engaging until an authoritative accepted-turn, running, or pending
* signal arrives (retry semantics — see ComposerPhase).
* @param hasContent - authoritative non-blank activity beyond a pending first
* prompt, a running turn, or a pending interaction.
* prompt, visible non-command Chat content, a running turn, or a pending interaction.
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/

View File

@@ -1110,3 +1110,60 @@ describe('completed reminder', () => {
expect(entry(manager, S2)?.completed).toBe(true)
})
})
describe('background-task mirror', () => {
const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
})
const tasksFrame = (sessionId: SessionId, tasks: unknown[]) =>
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
const first = manager.getListSnapshot().tasksBySession
expect(first[S1]).toEqual([view()])
expect(first[S2]?.[0]?.label).toBe('other')
// Last-wins: the newer whole set replaces, it does not merge.
manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })]))
expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })])
})
it('stores an emptied set as an absent key so absence and [] read alike', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
manager.handleMuxEnvelope(tasksFrame(S1, []))
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleMuxEnvelope({
rpcId: 's' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 },
})
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('drops the rows when the session is removed, whichever stream lands first', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
})
it('notifies list subscribers so an open header re-renders without a poll', async () => {
const manager = new SessionManager(new FakeApiClient())
const seen = vi.fn()
manager.subscribe(seen)
manager.handleMuxEnvelope(tasksFrame(S1, [view()]))
// The notifier batches on a microtask; the frame itself is already applied.
await Promise.resolve()
expect(seen).toHaveBeenCalled()
})
})

View File

@@ -8,6 +8,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import type {
@@ -132,7 +133,11 @@ const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
if (context.state === undefined || context.start === undefined) return null
return {
key: context.key,
kind: 'runtime-test-event',
kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal'
? 'command-input'
: context.start.event.type === 'command/run' || context.start.event.type === 'command/done'
? 'command'
: 'runtime-test-event',
id: context.id,
target: 'chat',
anchorSeq: context.start.event.seq,
@@ -272,6 +277,24 @@ describe('live event path', () => {
expect(snapshot.composerPhase).toBe('blank')
})
it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => {
const { session } = await opened([])
session.handleBlank(true)
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.commandRun(0, 'cmd-goal', 'goal', ' '))
feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.'))
expect(session.getSnapshot()).toMatchObject({
blank: true,
composerPhase: 'active',
})
expect(session.getSnapshot().chat.order.map(
key => session.getSnapshot().chat.nodes.get(key)?.kind,
)).toContain('command-input')
})
it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {