Merge origin/master into worktree/skill-catalog-hot-refresh

This commit is contained in:
Tianyi Cui
2026-07-29 16:40:40 +08:00
1089 changed files with 35627 additions and 9637 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/README.md
README.md: d16e395a42e491461c0862227205931894c27e39
README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f
README.md: 7a86e0f034264d4059e75775016d8d5d84600d8d
README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc

View File

@@ -28,21 +28,24 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
| [`ui/`](ui/README.md) | Human/client integrations: TUI and JSON-RPC, approval/interaction seams, ask-user tool | Product — stable surface |
| [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface |
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |

View File

@@ -28,21 +28,24 @@
| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 |
| [`web/`](web/README.md) | Web 能力系列seam、搜索获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 |
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
| [`todo/`](todo/README.md) | Todo规划系列面向模型的 `todo_write` 工具 | 产品:稳定表面 |
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 |
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 |
| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 产品:稳定表面 |
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude CodeCodex 协议格式库 | 产品:稳定表面 |
| [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 |
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
| [`session-projection/`](session-projection/README.md) | 投影 seam领域折叠单元供给全量值 | 产品:稳定表面 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 |
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
| [`ui/`](ui/README.md) | 人类/客户端集成:TUI 与 JSON-RPC、批准交互 seam、用户问答工具 | 产品:稳定表面 |
| [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
| [`host/`](host/README.md) | web GUI 宿主半侧API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
| [`client/`](client/README.md) | web GUI 浏览器半侧shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
| [`examples/`](examples/README.md) | 演示组合包agent-spine + TUI/CLI/ACP/JSON-RPC bin由叶节点加载 | 支持:示例基础设施 |
| [`support/`](support/README.md) | 支持基础设施testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home路径辅助函数、超时、保留策略 | 支持:小型、稳定、无 harness 依赖 |

View File

@@ -14,6 +14,7 @@ import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import { Readable, Writable } from 'node:stream'
import Schema from 'schemastery'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import {
AgentSideConnection,
ndJsonStream,
@@ -146,7 +147,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (record === undefined || record.agent.session !== session) return
try {
if (event.type === 'assistant/message') {
for (const block of event.data.content) {
for (const block of event.data.message.content) {
if (block.type === 'text' && block.text.length > 0) {
notify({
sessionId: record.agent.session.id,
@@ -274,7 +275,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
record.inflight = inflight
try {
record.agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
record.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
// The machine's send() contains listener failures and accepts
// any typed input; this guards a future synchronous throw so the
// slot cannot wedge.

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
@@ -49,7 +49,7 @@ describe('ACP automation output boundary', () => {
sessionId: SessionId('foreign'),
agentOptions: { provider: 'mock', model: 'mock' },
})
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await agent.whenIdle()
expect(harness.updates).toHaveLength(0)
})

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -72,7 +73,7 @@ describe('ACP prompt lifecycle', () => {
harness.ctx.on('agent/inbox/enqueue', (subject) => {
if (subject === agent && !injected) {
injected = true
agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } })
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }))
}
})
@@ -91,10 +92,10 @@ describe('ACP prompt lifecycle', () => {
inserted = true
const source = { kind: 'plugin', plugin: 'test' } as const
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
agent.session.append('user/message', {
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'autonomous work' }],
source,
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})

View File

@@ -1,6 +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: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46
README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
README.md: deb6b899c81cb8c335b4c1cffdde4797e0a8be92
README.zh.md: c2514308fb9f234e6d191a6b1a821ac3d195378b

View File

@@ -57,7 +57,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef
## UI presentation
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, output, and parsed exit status. Because the card shows the exit as its own pill, the `[exit code: N]` / `[killed by signal: …]` marker the parse consumes leaves the output; every other marker (truncation, timeout, sandbox) stays in it. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
## The tool builds its request from named args only
@@ -153,6 +153,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual.
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay and loses that line from the card body, because the parse treats it as the marker it consumes; a display-only known residual.
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters.

View File

@@ -57,7 +57,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
## UI 展示
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id因此使用通用执行卡片通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片包含命令、说明、cwd、输出和解析后的退出状态。由于卡片以独立的 pill 展示退出状态,解析所消耗的 `[exit code: N]` / `[killed by signal: …]` 标记会从输出中移除;其他所有标记(截断、超时、沙箱)都保留在输出中。后台启动只返回 task id因此使用通用执行卡片通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
## 工具仅使用具名参数构建请求
@@ -153,6 +153,6 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
## 已知限制与延期工作
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill这是仅影响展示的已知残留问题。
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill,并且该行会从卡片正文中丢失,因为解析会把它当作自己消耗的标记;这是仅影响展示的已知残留问题。
- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。
- **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。

View File

@@ -296,7 +296,9 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
if (isBackground || result.isError) {
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
}
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
// The exit marker becomes the card's exit pill, so it leaves the output body.
const { body, ...exit } = parseExitStatus(raw)
return { card: 'terminal', output: body, ...exit }
}
/**

View File

@@ -95,10 +95,23 @@ export function renderProcessRead(
}
/**
* Recover the structured exit status from a rendered {@link renderResult}
* string — the inverse of the status markers it appends. A killed marker
* yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both
* means a clean exit 0.
* The exit status recovered from a rendered result, with the output body that
* status was split off from.
*/
export type ParsedExitStatus =
& { body: string }
& ({ exitCode: number } | { signal: string })
/**
* Split a rendered {@link renderResult} string into its output body and the
* structured exit status — the inverse of the status markers it appends. A
* killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
* absent both means a clean exit 0.
*
* The consumed marker is removed from `body` because a terminal presentation
* shows the exit status as its own pill: leaving the marker in the output would
* render the exit twice. Other markers (timeout, sandbox denial) carry facts no
* pill shows, so they stay in the body.
*
* Replay only retains the rendered content text, not the original
* `BashRunResult`, so terminal presentation must recover the exit pill here.
@@ -106,12 +119,12 @@ export function renderProcessRead(
* that merely ends with marker-like text from matching unless the final line
* is indistinguishable from a real marker.
* @param text - rendered model-facing bash result.
* @returns the recovered terminal exit code or signal.
* @returns the marker-free body plus the recovered terminal exit code or signal.
*/
export function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
export function parseExitStatus(text: string): ParsedExitStatus {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
return { body: text, exitCode: 0 }
}

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
@@ -73,7 +74,7 @@ function findEvent<T extends SessionEvent['type']>(
function resultText(event: SessionEvent): string {
if (event.type !== 'tool/result') return ''
return event.data.content
return event.data.message.content[0].content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
@@ -111,7 +112,7 @@ describe('bash tool through the agent loop', () => {
const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl')
agent.followup({ content: [{ type: 'text', text: 'inspect the current session' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the current session' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const result = findEvent(events(agent), 'tool/result')
@@ -131,7 +132,7 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'run echo integration-ok' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run echo integration-ok' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const log = events(agent)
@@ -139,7 +140,7 @@ describe('bash tool through the agent loop', () => {
expect(toolCall.data.name).toBe('bash')
const toolResult = findEvent(log, 'tool/result')
expect(toolResult.data.isError).toBe(false)
expect(toolResult.data.message.content[0].isError).toBe(false)
expect(resultText(toolResult)).toBe('integration-ok\n')
// The second model call saw the tool result in its derived history.
@@ -150,7 +151,7 @@ describe('bash tool through the agent loop', () => {
expect(toolResultBlocks).toHaveLength(1)
const finalMessage = findEvent(log, 'assistant/message', 'last')
expect(finalMessage.data.content.some(
expect(finalMessage.data.message.content.some(
block => block.type === 'text' && block.text.includes('integration-ok'),
)).toBe(true)
})
@@ -163,11 +164,11 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'run exit 9' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run exit 9' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const toolResult = findEvent(events(agent), 'tool/result')
expect(toolResult.data.isError).toBe(false)
expect(toolResult.data.message.content[0].isError).toBe(false)
expect(resultText(toolResult)).toContain('[exit code: 9]')
})
@@ -183,11 +184,11 @@ describe('bash tool through the agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'run echo bg-ok in the background' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'run echo bg-ok in the background' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const firstResult = findEvent(events(agent), 'tool/result')
expect(firstResult.data.isError).toBe(false)
expect(firstResult.data.message.content[0].isError).toBe(false)
expect(resultText(firstResult)).toBe('started background task bash-1')
// The task settles on its own; the tool-tasks notice listener injects a
@@ -203,10 +204,10 @@ describe('bash tool through the agent loop', () => {
expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
// The next turn collects the output through the generic task tool.
agent.followup({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
const readResult = findEvent(events(agent), 'tool/result', 'last')
expect(readResult.data.isError).toBe(false)
expect(readResult.data.message.content[0].isError).toBe(false)
expect(resultText(readResult)).toContain('bg-ok')
expect(resultText(readResult)).toContain('[status: completed, exit code: 0]')
})

View File

@@ -911,22 +911,32 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => {
const ctx = await setup()
const present = ctx.tools.get('bash')!.presentResult!(
{ command: 'echo hi', description: 'echo' },
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
{ command: 'printf "hi\\n\\n"', description: 'echo' },
// A clean run renders no exit marker at all, so the body is the raw bytes.
{ content: [{ type: 'text', text: 'hi\n\n' }], isError: false },
)
// A terminal result keeps the RAW bytes (newlines intact) a terminal renderer
// needs; the bridge derives the fenced fallback. exitCode is parsed back from
// the [exit code: N] marker.
expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 })
// needs; the bridge derives the fenced fallback.
expect(present).toEqual({ card: 'terminal', output: 'hi\n\n', exitCode: 0 })
})
it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
const ctx = await setup()
const args = { command: 'x', description: 'x' }
const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 })
expect(nonzero).toEqual({ card: 'terminal', output: 'oops', exitCode: 3 })
const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
expect(killed).toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' })
})
it('bash presentResult: markers a pill CANNOT show (timeout, sandbox denial) stay in the terminal output', async () => {
const ctx = await setup()
const args = { command: 'x', description: 'x' }
const timedOut = ctx.tools.get('bash')!.presentResult!(
args,
{ content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false },
)
expect(timedOut).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 })
})
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
@@ -952,8 +962,11 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
const rendered = renderResult(c.result)
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
// Drop card + output; the remaining fields are the parsed exit.
const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
expect(exit).toEqual(c.expect)
// Whatever the parse consumed is gone from the body, so a card with an exit
// pill never shows the same status twice.
expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /)
}
})
@@ -964,6 +977,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
// newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
// Unparsed marker-like text is real output, so it is NOT stripped from the body.
// Same for a fake signal marker with no leading newline.
const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })

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 packages/client/README.md
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8

34
packages/client/README.md Normal file
View File

@@ -0,0 +1,34 @@
# client/ — web-GUI browser half
English | [中文](README.zh.md)
The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-<name>`.
| Package | Role | ctx key / slot |
|---|---|---|
| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) |
| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) |
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) |
| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` |
| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) |
| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) |
| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) |
| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) |
| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` |
| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` |
| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) |
| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) |
| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` |
| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) |
| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) |
| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) |
| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) |
Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.

View File

@@ -0,0 +1,34 @@
# client/ — web GUI 浏览器半侧
[English](README.md) | 中文
dsh web GUI 的浏览器侧shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-<name>`
| 包 | 角色 | ctx 键slot |
|---|---|---|
| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) |
| `modules/` | 客户端模块系统Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) |
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
| `runtime/` | 客户端 cordis 启动与无 React 对象服务slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
| `locale/` | 浏览器语言偏好(`zh``en`)与 ns×locale 词典注册表 | `ctx.locale` |
| `ui-slots/` | slot 注册表纯核心SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light``dark``system` | `ctx.theme` |
| `ui-primitives/` | 纯 React 原子图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) |
| `ui-layout/` | shell 三栏 AppFrame声明 `sidebar``conversation``details``conversation.empty` | `ctx.layout` |
| `ui-sidebar/` | 侧栏 shellWorkspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | slot 宿主) |
| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces``conversation.hero.workspace` |
| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | slot 宿主) |
| `ui-trajectory/` | TrajectoryWaterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view` |
| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` |
| `ui-slash/` | 输入触发流水线:光标下的 `/``@` 检测、分组候选菜单、源名册 | `ctx.slash` |
| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash` |
| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash` |
| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` |
| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer` |
| `ui-settings/` | 设置 shell触发 chrome + 模态面板;声明 `settings.*` slot | slot 宿主) |
| `ui-settings-general/` | 设置的无主文案chrome 内容 + General 分区骨架 | (填充 `settings.*` |
| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section` |
特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6
README.zh.md: ca5da643db443956c25399f07c8b460900942ad4

View File

@@ -4,6 +4,10 @@ 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.
## /api browser-trust fence
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.

View File

@@ -4,6 +4,10 @@
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## /api 浏览器信任栅栏
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取EventSource、图片、导航——这些头只发给可信目标既不带 `Origin` 也不带 Fetch-Metadata因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取而 Host 是重绑唯一伪造不了的请求头非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`部署需要让自己的服务权威被信任dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。

View File

@@ -30,9 +30,11 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
"@deepseek-ai/dsh-tools": "workspace:^",
"schemastery": "^3.18.0"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,129 @@
/**
* Browser-trust fence for every /api request. Defends the two confused-deputy
* paths a browser opens against a local HTTP API — DNS rebinding (Host names
* the attacker's domain while the socket reaches this server) and cross-site
* requests fired from a malicious page. The Host fence binds every request,
* browser-looking or not: over plain HTTP a browser attaches neither Origin
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
* headers go only to trustworthy destinations), so an unmarked request may
* still be a rebound browser read and Host is the one header rebinding cannot
* forge. Non-browser and remote clients pass the same fence via loopback, the
* CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
* Network reachability and authentication stay out of scope: binding policy
* belongs to the webserver config, and this fence is not an auth layer.
*/
import type { IncomingHttpHeaders } from 'node:http'
/** The request facts the fence reads (structural subset of IncomingMessage). */
interface ApiTrustRequest {
headers: IncomingHttpHeaders
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
try {
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
return new URL(`http://${authority}`)
} catch {
return undefined
}
}
/**
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged
* (case aside). Anything parsing would silently rewrite is refused as a typo
* that must fail the load loudly instead of being ignored until requests 403
* or quietly changing the grant: URL parts beyond the authority
* (`harness.internal/path`, `user@harness.internal` — which would authorize
* the embedded hostname), stripped whitespace, a dangling colon or
* zero-padded port (which would broaden an intended exact-port grant to every
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
* carries).
* @param entry - the configured value, verbatim.
*/
export function assertTrustedAuthority(entry: string): void {
const entryUrl = parseAuthority(entry)
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
}
/**
* Canonical form of a parsed authority: `hostname` when no port was written,
* else `hostname:port`. The port is judged from URL parses under both special
* schemes (their default ports differ, so `:80` and `:443` still count as
* explicit), never from the raw string, where WHATWG trimming would misread
* shapes like `host:port ` as port-less.
*/
function canonicalAuthority(entry: string, entryUrl: URL): string {
// An authority that parsed under http cannot fail under https.
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
}
/**
* Whether the request authority matches a `trustedHosts` entry. An entry with
* an explicit port matches that exact authority; a port-less entry matches the
* hostname on any port (the shape the CLI derives for IP-literal LAN serving,
* where the bound port may be OS-assigned). Both sides compare through WHATWG
* normalization, so case and a redundant `:80` never decide trust.
*/
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
return trustedHosts.some((entry) => {
const entryUrl = parseAuthority(entry)
if (entryUrl === undefined) return false
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
? entryUrl.hostname === hostUrl.hostname
: entryUrl.host === hostUrl.host
})
}
/**
* Decide whether one /api request may reach the RPC bridge.
* @param request - node HTTP request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
*/
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
// Host fence (DNS-rebinding defense), applied to every request: the browser
// fills Host from the URL it believes it is talking to, so a rebound page
// carries the attacker's domain here even though the socket lands on this
// server. There is no marker shortcut — a browser read over plain HTTP
// (EventSource, images, navigations) arrives with neither Origin nor
// Fetch-Metadata, indistinguishable from curl, and its response is readable
// by the rebound page.
const host = header(request.headers, 'host')
if (host === undefined) return false
const hostUrl = parseAuthority(host)
if (hostUrl === undefined) return false
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
// Cross-site fence: modern browsers label the initiator relationship on
// every fetch; an explicit cross-site marker is refused regardless of Origin.
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
// Origin fence: when a browser attaches an Origin it must be exactly this
// authority (compared through the same normalization as the Host). Absent
// Origin is fine — the Host fence above already bound the request. The
// literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
const origin = header(request.headers, 'origin')
if (origin === undefined) return true
try {
return new URL(origin).host === hostUrl.host
} catch {
return false
}
}

View File

@@ -8,10 +8,12 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
GoalsApi, GoalRef,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -5,8 +5,27 @@
// 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'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
@@ -24,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',
'',
@@ -83,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({
@@ -95,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
@@ -106,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' } } })
}
@@ -128,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' } } })
}
@@ -156,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 => {
@@ -181,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' } } })
@@ -255,13 +286,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 }
}
@@ -271,18 +302,139 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
return undefined
}
/** Fold the latest fixture title into the host's control-frame projection. */
function titleFrameOf(id: SessionId, log: readonly SessionEvent[]): Extract<MuxFrame, { type: 'session/title' }> | undefined {
const event = log.findLast(item => (item as { type: string }).type === 'session/title')
if (event === undefined) return undefined
const titleEvent = event as unknown as { seq: number; time: number; data: { title: string } }
return {
type: 'session/title',
sessionId: id,
title: titleEvent.data.title,
eventSeq: titleEvent.seq,
updatedAt: titleEvent.time,
/**
* Fixture parallel of the plan unit's double-event fold: `command/run`
* records named `plan` set the wanted target (`off` → false, else true);
* `plan/mode` commits and clears it. `wanted` is exposed for the prompt
* boundary (the fixture's agent/step parallel).
*/
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
let active = false
let wanted: boolean | null = null
for (const event of log) {
const item = event as unknown as { type: string; data?: Record<string, unknown> }
if (item.type === 'command/run' && item.data?.['name'] === 'plan') {
const args = item.data['args']
wanted = (typeof args === 'string' ? args : '').trim() !== 'off'
} else if (item.type === 'plan/mode') {
active = item.data?.['active'] === true
wanted = null
}
}
return { active, pending: wanted !== null && wanted !== active, wanted }
}
/** The plan projection's wire view over the full log. */
function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: boolean } {
const plan = foldPlan(log)
return { active: plan.active, pending: plan.pending }
}
/** Fixture parallel of the host's projection units: whole current values per key over the full log. */
/** Fixture preset table (the host PermissionService defaults). */
const PERMISSION_PRESETS: Record<string, { sandbox: string; approval: string; description: string }> = {
'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' },
}
/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */
function permissionSelectOf(
log: readonly SessionEvent[],
): { options: { value: string; name: string; description?: string }[]; currentValue: string } {
let preset: string | null = null
let sandbox = 'workspace-write'
let approval = 'ask'
for (const event of log) {
const item = event as { type: string; data: Record<string, unknown> }
if (item.type === 'permission/preset') preset = item.data['preset'] as string
else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string
else if (item.type === 'approval/policy') approval = item.data['policy'] as string
}
const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval
let currentValue = 'custom'
const folded = preset === null ? undefined : PERMISSION_PRESETS[preset]
if (preset !== null && folded !== undefined && matches(folded)) {
currentValue = preset
} else {
for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) {
if (matches(spec)) { currentValue = name; break }
}
}
return {
options: [
...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })),
...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [],
],
currentValue,
}
}
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
const values: Record<string, unknown> = {}
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
if (titleEvent !== undefined) {
values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title
}
// Always present (tool-todo unit composed): null when no plan stands.
values['todos'] = backscanTodos(log) ?? null
// Always present (permission service composed): the whole select.
values['permissions'] = permissionSelectOf(log)
// Always present (plan-mode unit composed): the {active, pending} view.
values['plan'] = planViewOf(log)
// Always present (GoalService unit composed): null before create / after clear.
values['goal'] = backscanGoal(log)
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
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 }]
}
// Goal fold: a round-zero goal-sourced user message advances the goal unit.
if (type === 'user/message') {
const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source
if (source?.kind === 'goal' && source.round === 0) {
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
}
return []
}
// 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,
}]
}
// Knob fold: any of the three whole-value knob events advances the select.
if (type === 'permission/preset' || type === 'sandbox/mode' || type === 'approval/policy') {
return [{
type: 'session/projection',
sessionId: id,
key: 'permissions',
value: permissionSelectOf(log),
seq: event.seq,
}]
}
// The plan unit advances on its two folded event kinds.
if (type === 'plan/mode' || (type === 'command/run'
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
return [{
type: 'session/projection',
sessionId: id,
key: 'plan',
value: planViewOf(log),
seq: event.seq,
}]
}
return []
}
/**
@@ -316,15 +468,69 @@ 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
}
/** Fixture-local mirror of the goal projection value (dsh-goal's GoalProjection shape). */
interface FxGoalProjection {
goal: {
id: string
revision: number
objective: string
phase: 'active' | 'paused' | 'blocked' | 'complete'
maxGoalRounds: number
}
roundsStarted: number
createdAt: number
updatedAt: number
}
/** One durable goal change riding a round-zero goal-sourced user message. */
type FxGoalChange =
| { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
| {
kind: 'goal/change'
version: 1
operation: 'create' | 'edit' | 'pause' | 'resume' | 'complete'
goal: FxGoalProjection['goal']
roundsStarted: number
createdAt: number
updatedAt: number
}
/**
* Current goal projection over the full log (host parallel: the GoalService
* unit's last-wins fold of goal/change whole values; clear returns null).
*/
function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
for (let i = log.length - 1; i >= 0; i--) {
const event = log[i] as unknown as {
type: string
data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } }
} | undefined
if (event === undefined || event.type !== 'user/message') continue
const source = event.data?.source
if (source?.kind !== 'goal' || source.round !== 0) continue
const change = source.change
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (change === undefined || change.kind !== 'goal/change') continue
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
}
return null
}
interface StreamConn<F> {
push(envelope: RpcRequest<F>): void
}
@@ -419,9 +625,43 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
updatedAt: fixtureEpoch,
}]
let nextWorkspace = 1
// In-memory browse tree behind the fixture's `browse` picker capability —
// deterministic content mirroring the design mock so assembled Web tests
// and snapshots can walk it. Leaves are materialized lazily: a child listed
// by its parent lists as empty until something is created inside it.
const FIXTURE_HOME = '/home/fixture'
const directoryTree = new Map<string, string[]>([
['/', ['home']],
['/home', ['fixture']],
[FIXTURE_HOME, ['Documents', 'Downloads', '.config']],
[`${FIXTURE_HOME}/Documents`, [
'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform',
'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog',
]],
])
const childrenOf = (path: string): string[] | undefined => {
const known = directoryTree.get(path)
if (known !== undefined) return known
const parent = path.slice(0, path.lastIndexOf('/')) || '/'
const name = path.slice(path.lastIndexOf('/') + 1)
return directoryTree.get(parent)?.includes(name) === true ? [] : undefined
}
const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => {
const crumbs = [{ name: '/', path: '/', hidden: false }]
let acc = ''
for (const segment of path.split('/').filter(Boolean)) {
acc += `/${segment}`
crumbs.push({ name: segment, path: acc, hidden: false })
}
return crumbs
}
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
/** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */
const pendingApprovalRpcId = mint()
const pendingApprovalId = 'fx-approval-1' as Extract<MuxFrame, { type: 'approval/requested' }>['approvalId']
/** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */
let approvalPending = true
const pendingQuestionRpcId = mint()
let questionPending = true
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
@@ -512,10 +752,49 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
emitMux(view === undefined
? { type: 'session/event', sessionId: id, event }
: { type: 'session/event', sessionId: id, event, view })
if ((event as { type: string }).type === 'session/title') {
// The raw title is already in this log, so the latest-title fold must find it.
emitMux(titleFrameOf(id, log) as Extract<MuxFrame, { type: 'session/title' }>)
// Host eager-drive parallel: a unit-advancing event pushes its finished value.
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
}
/** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */
const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => {
const ref = change.operation === 'clear' ? change.cleared : change.goal
const payload = change.operation === 'clear'
? { cleared: change.cleared, clearedAt: change.clearedAt }
: { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
append(id, {
type: 'user/message', surfaceOp: 'append',
data: userMessage(
text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
{ kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
),
})
return backscanGoal(logOf(id)) as FxGoalProjection
}
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
const fxMutateGoal = (
request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>,
ref: { id: string; revision: number },
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
): Promise<RpcResponse<{ ref: { id: never; revision: number } }>> => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const id = request.payload.sessionId
const current = backscanGoal(logOf(id))
if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) {
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
}
const goal = next(current)
if (goal === undefined) {
return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } })
}
const projection = appendGoalChange(id, {
kind: 'goal/change', version: 1,
operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete',
goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(),
})
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
}
/** At most one in-flight replay per session; cancel clears it. */
@@ -542,7 +821,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 {
@@ -553,7 +832,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 {
@@ -574,7 +853,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)
@@ -668,14 +947,18 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50)
// Tail page carries the session-level todo projection (host parallel: full-log backscan).
const todos = request.payload.beforeSeq === undefined ? backscanTodos(log) : undefined
// Tail page carries the projections block (host parallel: one consistent
// cut over the registered units; asOfSeq = window tail seq, -1 on an
// empty log — the host's session.seq-1 convention).
const projections = request.payload.beforeSeq === undefined
? { asOfSeq: log.length - 1, values: projectionValuesOf(log) }
: undefined
const doomed = failNextHistory
failNextHistory = false
const delay = historyDelayMs
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
if (doomed) throw new Error('fixture: simulated history transport failure')
return ok(request, { ...page, ...todos === undefined ? {} : { todos } })
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
},
models: request => ok(request, {
current: modelTargets.get(request.payload.sessionId)
@@ -739,14 +1022,20 @@ 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' } } })
// Boundary flush parallel (the host's agent/step seam): an outstanding
// /plan selection commits as plan/mode inside the opened turn.
const plan = foldPlan(logOf(id))
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
startReply(
id,
turn,
@@ -775,7 +1064,43 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
pickDirectory: request => ok(request, { path: null }),
// Deterministic native pick: the keyless lanes drive the full
// pick-then-adopt path without an OS chooser (design-mock content,
// same tree the browse primitives serve).
pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }),
listDirectory: (request) => {
const target = request.payload.path ?? FIXTURE_HOME
const children = childrenOf(target)
if (children === undefined) {
return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } })
}
return ok(request, {
path: target,
home: FIXTURE_HOME,
crumbs: crumbsOf(target),
entries: [...children].sort((a, b) => a.localeCompare(b))
.map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })),
// The fixture tree is tiny; no level ever reaches a backend bound.
truncated: false,
})
},
createDirectory: (request) => {
const parent = request.payload.path
const children = childrenOf(parent)
if (children === undefined) {
return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } })
}
// Same root special case as listDirectory's entry paths: a plain join
// under '/' would mint '//name' and fork the tree's identity.
const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}`
if (children.includes(request.payload.name)) {
return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } })
}
directoryTree.set(parent, [...children, request.payload.name])
directoryTree.set(target, [])
return ok(request, { path: target })
},
openPath: request => ok(request, { opened: true as const }),
},
workspace: {
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
@@ -875,29 +1200,92 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
commands: [
{ name: 'compact', description: 'fixture压缩当前会话上下文' },
{ name: 'echo', description: 'fixture回显参数', input: { hint: 'text to echo' } },
{ name: 'goal-fixture', description: 'fixture目标样本命令', input: { hint: '<objective>' } },
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
],
})
},
// Pure admission, mirroring the host: an admitted command logs the
// command/run + command/done lifecycle pair (mux-broadcast by append),
// and the response only reports resolution.
execute: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const line = request.payload.line.trim()
const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
const id = request.payload.sessionId
// Structured split mirroring the host parser: name + verbatim rawInput
// (separator whitespace included) — the run payload carries no line.
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
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已压缩假动作' },
})
const args = match?.[2] ?? ''
// /permission mirrors the host handler: switch through the knob
// events (each append pushes a permissions projection frame).
if (name === 'permission') {
const preset = args.trim()
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const spec = PERMISSION_PRESETS[preset]
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } })
} else if (spec === undefined) {
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } })
}
return ok(request, { matched: true as const, commandId })
}
if (name === 'goal-fixture') {
return ok(request, {
matched: true as const,
result: { kind: 'success' as const, text: `fixturegoal 已设置(${request.payload.sessionId}` },
})
if (name === 'goal') {
// Host parallel: /goal with an objective creates (or reports) the
// current goal; the command lifecycle pair brackets the mutation.
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
const objective = args.trim()
const current = backscanGoal(logOf(id))
let text: string
if (objective === '') {
text = current === null ? 'No goal is set. Usage: /goal <objective>' : `Current goal: ${current.goal.objective}`
} else if (current !== null && current.goal.phase !== 'complete') {
text = `A goal already exists (${current.goal.objective}). Clear it first.`
} else {
const created = appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'create',
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 },
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
})
text = `Goal created: ${created.goal.objective}`
}
append(id, { type: 'command/done', data: { commandId, kind: 'success', text } })
return ok(request, { matched: true as const, commandId })
}
return ok(request, { matched: false as const })
// Host parallel: /plan on an idle fixture session commits plan/mode
// immediately (the boundary flush covers only a running turn), so the
// outcome copy matches the immediate branch of the host handler.
const running = summaryOf(id)?.running === true
const outcomes: Record<string, string> = {
compact: 'fixture已压缩假动作',
echo: args.trim(),
plan: args.trim() === 'off'
? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.')
: (running
? 'Entering plan mode (applies from the next step). Use /plan off to leave.'
: 'Plan mode on. Use /plan off to leave.'),
}
const text = name === undefined ? undefined : outcomes[name]
if (name === undefined || text === undefined) return ok(request, { matched: false as const })
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
if (name === 'plan' && !running) {
const plan = foldPlan(logOf(id))
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
}
append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } })
return ok(request, { matched: true as const, commandId })
},
},
skills: {
@@ -911,6 +1299,62 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
})
},
},
goals: {
// Mutation-only mirror of the host handlers: each verb CAS-checks the
// projected current goal, appends the whole-value change (the mux
// stream and projection frame ride the shared append path), and
// acknowledges with the new ref only.
create: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const id = request.payload.sessionId
const current = backscanGoal(logOf(id))
if (current !== null && current.goal.phase !== 'complete') {
return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } })
}
const projection = appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'create',
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 },
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
})
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
},
edit: request => fxMutateGoal(request, request.payload.ref, current => ({
...current.goal,
revision: current.goal.revision + 1,
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
})),
pause: request => fxMutateGoal(request, request.payload.ref, current => (
current.goal.phase === 'active'
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
: undefined
)),
resume: request => fxMutateGoal(request, request.payload.ref, current => (
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
: undefined
)),
complete: request => fxMutateGoal(request, request.payload.ref, current => (
current.goal.phase === 'complete'
? undefined
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
)),
clear: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const id = request.payload.sessionId
const current = backscanGoal(logOf(id))
if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) {
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
}
appendGoalChange(id, {
kind: 'goal/change', version: 1, operation: 'clear',
cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(),
})
return ok(request, { cleared: true as const })
},
},
events: {
async *mux(_request, signal) {
const conn = new FxInbox<MuxFrame>()
@@ -920,18 +1364,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds.
for (const s of sessions) {
if (!s.running) continue
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? [])
if (title !== undefined) conn.push({ rpcId: mint(), payload: title })
const log = logs.get(s.sessionId) ?? []
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: log.length - 1 } })
// Post-subscribe projection baseline (host parallel: recomputed unit values ride push frames).
const values = projectionValuesOf(log)
for (const key of Object.keys(values)) {
conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } })
}
}
if (approvalPending) {
conn.push({
rpcId: pendingApprovalRpcId,
payload: {
type: 'approval/requested', sessionId: sid('fx-alpha'),
approvalId: pendingApprovalId,
toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)',
},
})
}
conn.push({
rpcId: pendingApprovalRpcId,
payload: {
type: 'approval/requested', sessionId: sid('fx-alpha'),
approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract<MuxFrame, { type: 'approval/requested' }>['approvalId'],
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
},
})
if (questionPending) {
conn.push({
rpcId: pendingQuestionRpcId,
@@ -969,6 +1419,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Same routing discipline as the host: rpcId first, then the payload's
// audit correlation; a settled or unknown id is not-pending.
if (message.rpcId === pendingApprovalRpcId) {
if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
const value = message.result.value as { approvalId?: unknown; outcome?: unknown }
if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
approvalPending = false
emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome })
return Promise.resolve({ accepted: true })
}
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
@@ -1027,6 +1490,9 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
case 'host.createDirectory': return this.api.host.createDirectory(request)
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)
@@ -1036,6 +1502,12 @@ export class FixtureApiClient extends AbstractApiClient {
// 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)
case 'goal.create': return this.api.goals.create(request)
case 'goal.edit': return this.api.goals.edit(request)
case 'goal.pause': return this.api.goals.pause(request)
case 'goal.resume': return this.api.goals.resume(request)
case 'goal.complete': return this.api.goals.complete(request)
case 'goal.clear': return this.api.goals.clear(request)
}
}

View File

@@ -13,13 +13,15 @@ import { WebApiClient } from './web-api-client.ts'
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'

View File

@@ -1,11 +1,12 @@
/** Host HTTP bridge for browser-client RPC. */
import type { Context } from 'cordis'
import z from 'schemastery'
// Activates the httpServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
export { API_PATH } from './api-path.ts'
@@ -15,19 +16,42 @@ export const name = 'client-connection'
/** Services required before mounting the route. */
export const inject = ['httpServer', 'apiProxy']
/** Plugin config: the deployment's non-loopback serving authorities. */
export interface ConnectionConfig {
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
}
export const Config: z<ConnectionConfig> = z.object({
trustedHosts: z.array(String).default([]),
})
/**
* Mounts the API gateway under the browser transport prefix.
* Mounts the API gateway under the browser transport prefix. Every request on
* the prefix passes the browser-trust fence first (DNS-rebinding and
* cross-site defense — [api-request-trust](./api-request-trust.ts)).
* @param ctx - Host plugin context.
* @param config - resolved plugin config (schema defaults applied).
*/
export function apply(ctx: Context): void {
export function apply(ctx: Context, config?: ConnectionConfig): void {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
const apiHandler = toFetchHandler(ctx.apiProxy)
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
if (pathname === `${API_PATH}/host.pickDirectory`
&& !isTrustedNativeDialogRequest(req)) {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return

View File

@@ -1,52 +0,0 @@
/** Trust check for browser requests that can open an operating-system dialog. */
import type { IncomingHttpHeaders } from 'node:http'
interface NativeDialogRequest {
headers: IncomingHttpHeaders
socket: { remoteAddress?: string | undefined }
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
function isLoopback(address: string | undefined): boolean {
if (address === undefined) return false
if (address === '::1') return true
const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
const first = ipv4.split('.')[0]
return first === '127'
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/**
* Require a local socket plus browser-controlled same-origin metadata.
* @param request - the node HTTP request facts used by the carrier guard.
* @returns true only for a same-origin browser request whose peer and URL are loopback.
*/
export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
if (!isLoopback(request.socket.remoteAddress)) return false
if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
const origin = header(request.headers, 'origin')
const host = header(request.headers, 'host')
if (origin === undefined || host === undefined) return false
try {
const parsed = new URL(origin)
const hostUrl = new URL(`http://${host}`)
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
&& parsed.host === host
&& isLoopbackHostname(parsed.hostname)
&& isLoopbackHostname(hostUrl.hostname)
} catch {
return false
}
}

View File

@@ -0,0 +1,108 @@
/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
import { describe, expect, it } from 'vitest'
import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
return { headers }
}
describe('isTrustedApiRequest', () => {
it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
// Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
// reads (EventSource, images, navigations), so a rebound-origin GET is
// markerless and its response readable: no marker shortcut may exist.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
expect(isTrustedApiRequest(request({}), [])).toBe(false)
})
it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) {
expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true)
}
})
it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
expect(isTrustedApiRequest(request({
host: 'evil.example:3080',
origin: 'http://evil.example:3080',
'sec-fetch-site': 'same-origin',
}), [])).toBe(false)
})
it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false)
expect(isTrustedApiRequest(request(headers), [])).toBe(false)
})
it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => {
expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true)
// An unparsable entry never matches; it must not poison the rest of the list.
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false)
})
it('refuses cross-origin browser markers even on a loopback Host', () => {
// Origin present and different → cross-site request that survived preflight rules.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false)
// Explicit cross-site label → refused regardless of Origin.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false)
// Opaque origin (sandboxed iframe, file: page) parses to no authority.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
})
it('accepts a same-origin browser request, with or without an Origin header', () => {
expect(isTrustedApiRequest(request({
host: 'localhost:3080',
origin: 'http://localhost:3080',
'sec-fetch-site': 'same-origin',
}), [])).toBe(true)
// Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site.
expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
})
it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
}
// WHATWG parsing would quietly read a hostname out of each of these; the
// config boundary must refuse them instead of authorizing the prefix.
for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
// WHATWG trimming would silently strip these; the entry must fail instead.
for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
// WHATWG parsing would silently rewrite these — a dangling colon or
// zero-padded port would broaden an intended exact-port grant to every
// port, and non-canonical host spellings would not read back as written.
for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
})
it('never lets stray whitespace broaden an exact-port entry to every port', () => {
// Defense in depth below the load-time assert: the explicit-port judgment
// reads the parsed URL, so a trimmed `host:port ` entry stays exact.
const trusted = ['harness.internal:3080 ']
expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true)
})
it('refuses malformed or untrusted authorities on browser requests', () => {
const markers = { 'sec-fetch-site': 'same-origin' }
expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false)
})
})

View File

@@ -1,8 +1,9 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -66,6 +67,20 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -88,6 +103,9 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
readonly workspace: IApiClient['workspace'] = {
@@ -107,10 +125,12 @@ 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: [] }))
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
= () => 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)),
@@ -121,6 +141,15 @@ export class FakeApiClient implements IApiClient {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}
readonly goals: IApiClient['goals'] = {
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => {
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'])
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
// input hint rides only the commands declaring it.
const echo = commands.find(c => c.name === 'echo')
expect(echo?.input?.hint).toBeTruthy()
@@ -36,22 +36,39 @@ describe('createFixtureApi commands/skills', () => {
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
it('executes a known command line and reports matched with a result', async () => {
it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
const api = createFixtureApi()
const frames: unknown[] = []
const abort = new AbortController()
const stream = api.events.mux(req({}), abort.signal)
const pump = (async () => {
for await (const frame of stream) {
frames.push(frame.payload)
if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
}
})()
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' })
expect(response.result.value).toMatchObject({ matched: true })
expect(response.result.value.commandId).toBeTruthy()
await pump
const events = frames
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
.map(f => f.event)
expect(events).toMatchObject([
{ type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } },
{ type: 'command/done', data: { kind: 'success', text: 'hello world' } },
])
expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)
})
it('addresses execute to the session (result text carries the id)', async () => {
it('addresses execute to the session; an unknown session errs', async () => {
const api = createFixtureApi()
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal 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)
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal)
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
@@ -60,8 +77,8 @@ describe('createFixtureApi commands/skills', () => {
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()
// Pure admission value: the matched bit is the whole response shape.
expect(response.result.value).toEqual({ matched: false })
}
})

View File

@@ -65,12 +65,26 @@ describe('createFixtureApi', () => {
const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 }))
if (!clamped.result.ok) throw new Error('clamped failed')
expect(clamped.result.value.events).toEqual([])
// Unknown session: empty page, not an error (history of a bare id).
// Unknown session: empty page, not an error (history of a bare id). The
// 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')
// Fixture composes the todos + plan units (host parallel when tool-todo
// and plan-mode are mounted): the empty-log values.
expect(empty.result.value).toEqual({
events: [],
hasMore: false,
events: [], hasMore: false, projections: { asOfSeq: -1, values: {
todos: null,
// Permission unit composed: the composition-default select.
permissions: {
options: [
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
],
currentValue: 'workspace-write',
},
plan: { active: false, pending: false },
goal: null,
} },
})
})
@@ -205,7 +219,7 @@ describe('createFixtureApi', () => {
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 4) abort.abort()
if (envelopes.length >= 8) abort.abort()
}
return envelopes
}
@@ -213,11 +227,16 @@ describe('createFixtureApi', () => {
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
expect(first[1]?.payload).toMatchObject({ type: 'session/title', sessionId: 'fx-alpha', title: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[2]?.rpcId).toBe(first[2]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[3]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[3]?.rpcId).toBe(first[3]?.rpcId)
// Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units).
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[7]?.rpcId).toBe(first[7]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
@@ -304,6 +323,44 @@ describe('createFixtureApi', () => {
})).toEqual({ accepted: true })
})
it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => {
const api = createFixtureApi()
// Discover the resident approval's stable rpcId from the mux baseline.
const abort = new AbortController()
const seen: { rpcId: string; frame: MuxFrame }[] = []
const consuming = (async () => {
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload })
})()
await vi.waitFor(() => {
expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true)
})
const requested = seen.find(s => s.frame.type === 'approval/requested')
if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable')
const approvalId = requested.frame.approvalId
// Routed but malformed answers.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
// The real answer settles the question and broadcasts resolved.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } }))
.toEqual({ accepted: true })
await vi.waitFor(() => {
expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true)
})
// Settled: a duplicate answer is late, and a fresh mux open replays nothing.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } }))
.toEqual({ accepted: false, reason: 'not-pending' })
abort.abort()
await consuming
const abort2 = new AbortController()
const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2)
expect(replayed.some(f => f.type === 'approval/requested')).toBe(false)
})
it('describe answers the fixture identity', async () => {
const api = createFixtureApi()
const response = await api.host.describe(req({}))
@@ -312,6 +369,22 @@ describe('createFixtureApi', () => {
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
})
it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => {
const api = createFixtureApi()
const created = await api.host.createDirectory(req({ path: '/', name: 'srv' }))
if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.path).toBe('/srv')
const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal)
if (!listed.result.ok) throw new Error('list failed')
expect(listed.result.value.crumbs).toEqual([
{ name: '/', path: '/', hidden: false },
{ name: 'srv', path: '/srv', hidden: false },
])
const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal)
if (!root.result.ok) throw new Error('root list failed')
expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false })
})
it('workspace.list serves the resident account and create reuses on path collision', async () => {
const api = createFixtureApi()
const listed = await api.workspace.list(req({}))
@@ -619,11 +692,11 @@ describe('createFixtureApi', () => {
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
await vi.waitFor(() => {
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true)
expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true)
})
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title')
const titleControlIndex = seen.findIndex(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')
const titleControlIndex = seen.findIndex(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')
expect(titleControlIndex).toBe(rawTitleIndex + 1)
// But history serves the silent event (the client's repull finds it).
const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
@@ -695,6 +768,29 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
if (!moved.result.ok) throw new Error('workspace move failed')
expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
// Goal lifecycle over the fixture fold: create → edit → pause → resume → complete → clear;
// every mutation acknowledges with the NEW CAS ref (state rides the projection frames).
const goalCreated = await client.goals.create({ sessionId: id, objective: 'ship it' })
if (!goalCreated.result.ok) throw new Error('goal create failed')
let ref = goalCreated.result.value.ref
expect(ref.revision).toBe(1)
const edited = await client.goals.edit({ sessionId: id, ref, objective: 'ship it v2' })
if (!edited.result.ok) throw new Error('goal edit failed')
ref = edited.result.value.ref
const paused = await client.goals.pause({ sessionId: id, ref })
if (!paused.result.ok) throw new Error('goal pause failed')
ref = paused.result.value.ref
const resumed = await client.goals.resume({ sessionId: id, ref })
if (!resumed.result.ok) throw new Error('goal resume failed')
ref = resumed.result.value.ref
// A stale ref loses the CAS check.
expect((await client.goals.pause({ sessionId: id, ref: { ...ref, revision: 1 } })).result.ok).toBe(false)
const completed = await client.goals.complete({ sessionId: id, ref })
if (!completed.result.ok) throw new Error('goal complete failed')
ref = completed.result.value.ref
// complete → complete is an invalid transition.
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
})
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {

View File

@@ -1,57 +0,0 @@
import type { IncomingHttpHeaders } from 'node:http'
import { describe, expect, it } from 'vitest'
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
function request(
remoteAddress: string | undefined,
headers: IncomingHttpHeaders = {
host: '127.0.0.1:3080',
origin: 'http://127.0.0.1:3080',
'sec-fetch-site': 'same-origin',
},
) {
return { socket: { remoteAddress }, headers }
}
describe('native dialog request trust', () => {
it('accepts loopback same-origin browser requests', () => {
expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
expect(isTrustedNativeDialogRequest(request('::1', {
host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
})
it('rejects remote sockets and requests without matching browser metadata', () => {
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
})
})

View File

@@ -1,4 +1,6 @@
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { EventEmitter } from 'node:events'
import { Readable } from 'node:stream'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -6,46 +8,113 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, inject } from '../src/index.ts'
describe('connection node half', () => {
it('registers the /api prefix route and removes it with the fiber', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
ctx.provide('httpServer', httpServer as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
/** Structural httpServer fake: the plugin only touches register(). */
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
return {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
}
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
function fakeRequest(headers: Record<string, string>): IncomingMessage {
const request = Readable.from([]) as unknown as IncomingMessage
Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
return request
}
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
const state: { status?: number; body?: unknown } = {}
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(value: number) { state.status = value; return this },
write() { return true },
end(this: { writableEnded: boolean }, value?: unknown) {
if (value !== undefined) state.body = value
this.writableEnded = true
return this
},
}) as unknown as ServerResponse
return { response, state }
}
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
return { routes, dispose: () => fiber.dispose() }
}
describe('connection node half', () => {
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
const routes: WebRoute[] = []
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
// The apply throw also escapes cordis as a late rejection — the shape the
// boot's installFailLoud is contracted to catch. Capture it so the run
// stays clean, same pattern as the webserver bind-failure test.
const rejections: unknown[] = []
const onUnhandled = (err: unknown): void => { rejections.push(err) }
process.on('unhandledRejection', onUnhandled)
try {
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
expect(routes).toHaveLength(0)
for (let i = 0; i < 100 && rejections.length === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('registers the /api prefix route and removes it with the fiber', async () => {
const { routes, dispose } = await mounted()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
let status: number | undefined
let body: unknown
const deniedRequest = {
url: '/api/host.pickDirectory',
headers: {
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
},
socket: { remoteAddress: '192.168.1.8' },
} as unknown as IncomingMessage
const deniedResponse = {
writeHead(value: number) { status = value; return this },
end(value?: unknown) { body = value; return this },
} as unknown as ServerResponse
await routes[0]!.handler(deniedRequest, deniedResponse)
expect(status).toBe(403)
expect(body).toBe('forbidden')
await fiber.dispose()
await dispose()
expect(routes).toHaveLength(0)
})
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
const { routes, dispose } = await mounted()
const { response, state } = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
}), response)
expect(state.status).toBe(403)
expect(state.body).toBe('forbidden')
await dispose()
})
it('passes loopback and declared-authority requests through to the bridge', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
// Loopback, no browser markers (curl shape): the fence passes; the carrier
// answers 404 for a GET unary path — proof the bridge ran.
const loopback = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
expect(loopback.state.status).toBe(404)
// LAN authority declared as a port-less IP literal — the shape the CLI
// derives for `--host 0.0.0.0` — passes markerless curl on any port.
const lan = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
expect(lan.state.status).toBe(404)
// Declared public authority, same-origin browser shape.
const declared = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
}), declared.response)
expect(declared.state.status).toBe(404)
await dispose()
})
})

View File

@@ -15,6 +15,9 @@
{
"path": "../../core/session"
},
{
"path": "../../ui/commands"
},
{
"path": "../../util/brand"
},

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

@@ -32,10 +32,13 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"zustand": "~4.4.7"

View File

@@ -0,0 +1,63 @@
/**
* The outward session face. Feature packages never see the concrete Session
* class: components read conversation state through `useSession` (the
* ObservableSnapshot half), and orchestration code calls the behavior verbs
* below — nothing else. Widening this interface is the explicit act of
* widening what features may do to a session (and what every test fixture
* must stub); runtime-internal entry points (history staging, wire-frame
* dispatch) stay on the class, invisible out here.
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */
export interface ProjectionsFace {
/**
* The identity-stable bare observable for one projection key (absence is
* an `undefined` snapshot, never a missing face).
* @param key - projection key.
* @returns the key's value face.
*/
faceOf(key: string): ObservableSnapshot<unknown>
}
/** Identity plus the behavior verbs features may invoke on a session. */
export interface ISession {
/** The session's host identity (agent id — same axis). */
readonly sessionId: SessionId
/** Host-computed projection values by key (the useProjection seat). */
readonly projections: ProjectionsFace
/**
* Send a prompt into the session.
* @param content - model-facing content blocks.
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Cancel the running turn.
* @returns acceptance, or the business error.
*/
cancel(): Promise<RpcResult<{ accepted: true }>>
/**
* Extend the history window backwards (older messages pagination).
* @returns completion; failures land in snapshot.openState/loadingOlder.
*/
loadOlder(): Promise<void>
/**
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle).
* @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure.
*/
command(line: string): Promise<RpcResult<{ matched: boolean }>>
}
/**
* The full outward face: behavior verbs plus the conversation read side
* (the `useSession` hook source). This is the type carried by
* `SessionBinding.session` and the provide channel.
*/
export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>

View File

@@ -0,0 +1,47 @@
/**
* Cross-domain sessions face: the contract surface sibling domains (today:
* workspaces) consume instead of the sessions implementation. The sessions
* domain satisfies it structurally — SessionsService is assignable, checked
* wherever the assembly layer or a test injects the real service — so
* widening this face is the explicit act of widening the inter-domain
* dependency.
*/
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot } from './store.ts'
/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */
export interface SessionsPortSummary {
id: SessionId
/** Empty-log bit (blank sessions are reused by New Session instead of minting another). */
blank: boolean
cwd?: string
updatedAt: number
}
/** Session-list facts sibling domains read: readiness, selection, and the row map. */
export interface SessionsPortList {
ids: SessionId[]
byId: Record<SessionId, SessionsPortSummary>
current: SessionId | undefined
phase: 'pending' | 'ready'
}
/** The sessions-service face injected into sibling domains. */
export interface SessionsPort {
/** Observable list snapshot (read face only; writes stay inside the sessions domain). */
readonly list: ObservableSnapshot<SessionsPortList>
/**
* Create a session on the host.
* @param opts - target workspace.
* @returns the new session id.
*/
create(opts: { workspaceId: WorkspaceId }): Promise<SessionId>
/**
* Select a session as current.
* @param id - session id (must exist in the list store).
*/
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
}

View File

@@ -0,0 +1,64 @@
/**
* The outward sessions-service face — what `ctx.sessions` exposes to feature
* packages and the renderer host, and therefore exactly what the test
* runtime's sessions double must implement. Wire-pump entry points
* (handleMuxEnvelope/handleConnected/refresh) and runtime internals stay on
* the concrete class; cross-domain consumers keep the narrower
* [SessionsPort](./sessions-port.ts). Widening this interface is the
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type {
SessionBinding, SessionListState, SessionProvideDescriptor,
} from '../sessions/service.ts'
import type { SessionFace } from './session.ts'
import type { ObservableSnapshot } from './store.ts'
/** The sessions-service face injected as `ctx.sessions`. */
export interface ISessions {
/** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
readonly list: ObservableSnapshot<SessionListState>
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* Select a session as current.
* @param id - session id (must exist in the list; unknown ids fail loud).
*/
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Register a per-session standard-props provider (hooks become `use<Name>`
* selector hooks on the render side; props spread verbatim).
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider.
*/
provide(descriptor: SessionProvideDescriptor): () => void
/**
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id.
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined
/**
* Read the Agent scope tag off a context (service-method seam: fetch
* bundles must reach scope resolution through ctx.sessions).
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined
/**
* Resolve the session face behind an Agent-scoped context.
* @param ctx - an Agent-scoped context.
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
*/
sessionOf(ctx: Context): SessionFace | undefined
/**
* Resolve the stable session binding (scope-addressed assembly feed).
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined
}

View File

@@ -0,0 +1,79 @@
/**
* The outward workspaces-service face — what `ctx.workspaces` exposes to
* feature packages and the renderer host, and therefore exactly what the
* test runtime's workspaces double must implement. Wire-pump entry points
* (handleHostEnvelope/handleConnected/refresh/startInitialSelection) stay on
* the concrete class. Widening this interface is the explicit act of
* widening what features may do to the workspaces domain.
*/
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { WorkspaceListState } from '../workspaces/service.ts'
import type { ObservableSnapshot } from './store.ts'
/** The workspaces-service face injected as `ctx.workspaces`. */
export interface IWorkspaces {
/** The useWorkspaces standard feed (read face — writes stay inside the domain). */
readonly list: ObservableSnapshot<WorkspaceListState>
/**
* Connect a Workspace to its reusable or freshly created blank session.
* @param workspaceId - target workspace.
* @returns the connected session id.
*/
connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
/**
* The New Session flow: connect the target (or recent) Workspace and open
* the resulting session; failures surface on the session list state.
* @param workspaceId - explicit target; omitted uses the recency projection.
*/
startSession(workspaceId?: WorkspaceId): void
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* @returns the created or idempotently resolved Workspace.
*/
create(input: { name: string } | { path: string }): Promise<WorkspaceView>
/**
* Open the Host's native directory picker.
* @returns the selected path, or null when the user cancelled.
*/
pickDirectory(): Promise<string | null>
/**
* List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory.
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
* @returns the level's listing with breadcrumb ancestry.
*/
listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
/**
* Create one child directory through the Host's `browse` capability.
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment.
* @returns the created directory's absolute path.
*/
createDirectory(path: string, name: string): Promise<string>
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.
*/
openPath(path: string): Promise<void>
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
* @param title - the new display title.
* @returns the updated Workspace view.
*/
rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>
/**
* Delete a Workspace (its sessions fall back to the unaccounted group).
* @param workspaceId - target workspace.
*/
delete(workspaceId: WorkspaceId): Promise<void>
/**
* Move an accounted session within/into a Workspace's ordered list.
* @param workspaceId - target workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the updated Workspace view.
*/
insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
}

View File

@@ -7,33 +7,48 @@ import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
export { SlotsService } from './slots.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
// The provide channel is shared with the client test runtime (one
// materialization/projection implementation; no test-side mirror to drift).
export { SessionProvideChannel } from './sessions/provide.ts'
export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { ISessions } from './contract/sessions.ts'
export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
export type {
DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
// Runtime owns the snapshot store; web-react only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
// Projection value store (session-projection RFC, push model): host-computed
// whole values per key; domains ship projection support with zero client code.
export type {
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
} from './sessions/projection-store.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** Client-side Cordis context after declaration merging. */
@@ -59,12 +74,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */
sessionId: SessionId
/** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */
useProjection: UseProjection
}
/** Standard kit for slots that remain mounted while current session changes. */
interface SessionMaybeStandardProps {
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
/** Current session id; absent in the no-session state. */
sessionId: SessionId | undefined
/** Key-addressed projection reader; every key reads absent while no session is current. */
useProjection: UseProjection
}
/** Props injected into every global slot component. */
interface GlobalStandardProps {
@@ -99,8 +118,10 @@ declare module 'cordis' {
}
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
workspaces: import('./workspaces/service.ts').WorkspacesService
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** The outward face only; the concrete service stays inside the runtime. */
workspaces: import('./contract/workspaces.ts').IWorkspaces
}
}
@@ -133,6 +154,12 @@ export function apply(ctx: Context): void {
workspaces.handleConnected()
ctx.emit('connection/reset')
},
onStateChange: (state) => {
// Generation death fires before any next-generation frame can arrive
// (reconnect replays flow from stream open, ahead of onConnected):
// the only safe moment to drop generation-scoped interaction state.
if (state === 'reconnecting') sessions.handleDisconnected()
},
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
}

View File

@@ -3,6 +3,7 @@
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
// string here (narrow to real brands when convenient).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
@@ -120,6 +121,31 @@ export interface UnknownSurfaceNode {
data: unknown
}
/**
* One slash-command lifecycle folded from the log-only `command/run` /
* `command/done` pair (paired by commandId, mirroring tool call↔result).
* Log-only events never enter the surface fold, so the FoldAdapter indexes
* them separately and merges the nodes into the flow by seq. A window cut
* between the pair soft-falls like tool pairs: a done with no in-window run
* still builds a node (name/args null), and a run with no done renders as
* still executing.
*/
export interface CommandNode {
kind: 'command'
/** Seq of the command/run event; the done event's seq when only the done is in-window. */
seq: number
/** Unix epoch ms of the anchoring event. */
time: number
/** Pairing id minted by the host executor. */
commandId: CommandId
/** Command name (run payload's structured field); null when the run fell outside the window. */
name: string | null
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
args: string | null
/** Settlement outcome (done payload); null while the command is still executing. */
outcome: { kind: 'success' | 'error'; text?: string } | null
}
/** Finalized conversation node union (kind discriminates; seq is the React key). */
export type ConversationNode =
| UserMessageNode
@@ -127,6 +153,7 @@ export type ConversationNode =
| SteeringMessageNode
| ContextMessageNode
| ToolResultNode
| CommandNode
| UnknownSurfaceNode
/**
@@ -243,7 +270,4 @@ 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

@@ -8,8 +8,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationNode } from './conversation.ts'
import type { CommandNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
@@ -56,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,
@@ -99,6 +102,15 @@ export class FoldAdapter {
private callIdx = new Map<string, CallIndexEntry>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so the surface fold never
* emits it; this index folds the pair (done settles its run's node in
* place) and nodes() merges the products into the flow by seq. Window cuts
* soft-fall like tool pairs: a done with no in-window run still builds a
* node.
*/
private commandIdx = new Map<string, CommandNode>()
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
* reference-stability contract (§A.9.4) starts here. */
@@ -128,10 +140,14 @@ export class FoldAdapter {
this.degraded = false
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) this.indexCall(event, views?.[i])
if (event !== undefined) {
this.indexCall(event, views?.[i])
this.indexCommand(event)
}
}
}
@@ -145,6 +161,7 @@ export class FoldAdapter {
this.rev++
this.padded.push(event)
this.indexCall(event, view)
this.indexCommand(event)
}
/**
@@ -180,7 +197,23 @@ export class FoldAdapter {
this.nodeCache.set(seq, node)
out.push(node)
}
const value = { nodes: out, degraded: this.degraded }
// Command nodes fold outside the surface (log-only events); merge by seq.
// Both inputs are seq-ascending (surface order and run-index insertion
// order share the log order), so one linear merge keeps flow order.
let nodes = out
if (this.commandIdx.size > 0) {
nodes = []
const commands = [...this.commandIdx.values()]
let next = 0
for (const node of out) {
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
nodes.push(cmd)
}
nodes.push(node)
}
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
}
const value = { nodes, degraded: this.degraded }
this.nodesResult = { rev: this.rev, value }
return value
}
@@ -195,6 +228,36 @@ export class FoldAdapter {
return seqs
}
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
private indexCommand(event: SessionEvent): void {
// Log-only plugin events: the host-side dsh-commands declaration cannot
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
})
return
}
if ((event.type as string) !== 'command/done') return
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: null, args: null, outcome,
})
return
}
// Settle in place: a fresh node object (published references stay immutable).
this.commandIdx.set(data.commandId, { ...run, outcome })
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)

View File

@@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary {
title?: string
}
/** One flattened session-list row (summary + lineage indent depth). */
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
export interface SessionListEntry {
sessionId: SessionId
title?: string
@@ -19,6 +19,8 @@ export interface SessionListEntry {
blank: boolean
parentSessionId?: SessionId
cwd?: string
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
waitingApproval: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
@@ -28,9 +30,10 @@ export interface SessionListEntry {
* follows the established input order; this projection never re-sorts a
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
@@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
return
}
visited.add(s.sessionId)
out.push({ ...s, depth })
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
const kids = children.get(s.sessionId)
if (kids === undefined) return
for (const kid of kids) walk(kid, depth + 1)

View File

@@ -9,7 +9,12 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
// Type-only merge edge: the title domain's client-namespace outlet declares
// the 'title' projection key this manager projects into list rows (and any
// useProjection('title') consumer reads). Zero value imports by construction.
import type {} from '@deepseek-ai/dsh-session-title/client'
import { Notifier } from './notifier.ts'
import { ProjectionValueStore } from './projection-store.ts'
import { Session } from './session.ts'
/**
@@ -43,12 +48,6 @@ type SessionListMutation =
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
/** Latest title control snapshot retained independently of list/instance arrival. */
interface SessionTitleSnapshot {
title: string
eventSeq: number
updatedAt: number
}
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
export class SessionManager {
@@ -58,7 +57,16 @@ export class SessionManager {
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
* replays of the same requested frame). Manager-owned rather than read off Session instances
* because the sidebar must light up for sessions never instantiated. Cleared per connection
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
/** Per-session projection value stores, retained independently of instance arrival (the
* title-snapshot precedent, generalized): push frames land here whether or not the Session
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
* same store so history-baseline seeding and frames converge on one row set. */
private readonly projectionStores = new Map<SessionId, ProjectionValueStore>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
@@ -163,9 +171,23 @@ export class SessionManager {
onEngaged: (engaged) => {
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
},
projections: this.projectionStore(sessionId),
})
}
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
private projectionStore(sessionId: SessionId): ProjectionValueStore {
let store = this.projectionStores.get(sessionId)
if (store === undefined) {
store = new ProjectionValueStore()
// List rows project off store keys (title); any-key changes re-enter
// the manager's own batched rebuild channel.
store.subscribeAny(() => { this.notifier.markDirty() })
this.projectionStores.set(sessionId, store)
}
return store
}
// ---- List surface ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
@@ -195,6 +217,19 @@ export class SessionManager {
session.handleBlank(s.blank)
session.handleRunning(s.running)
}
// Seed each row's projection baseline into the per-session value
// store (cold titles surface without opening the session). Per-key
// apply, not seed(): the list block is a partial baseline — the
// cold cache serves only version-matching keys — so an absent key
// must not clear; higher-seq-wins still keeps a stale list block
// from overwriting a newer push frame or tail baseline.
for (const s of result.value.items) {
const block = s.projections
if (block === undefined) continue
const store = this.projectionStore(s.sessionId)
const values = block.values as Record<string, unknown>
for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq)
}
} else {
this.listState = 'error'
this.listError = result.error
@@ -302,23 +337,20 @@ export class SessionManager {
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
if (frame.type === 'session/title') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
this.titleSnapshots.set(frame.sessionId, {
title: frame.title,
eventSeq: frame.eventSeq,
updatedAt: frame.updatedAt,
})
if (frame.type === 'session/projection') {
// Finished host-computed value: land it in the resident store whether or
// not the Session is instantiated (list rows read the 'title' key). The
// synchronous markDirty keeps the list snapshot same-tick fresh (the
// store's own any-key channel is microtask-batched).
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq)
this.notifier.markDirty()
return
}
if (frame.type === 'session/subscribed') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq > frame.lastSeq) {
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
}
// 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)
this.notifier.markDirty()
// New mux-generation baseline: buffered session/queued frames belong to
// the previous generation and the host is about to resend the live
// snapshot — drop them, or every reconnect appends a duplicate batch
@@ -333,6 +365,22 @@ export class SessionManager {
}
}
}
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
// every session, instantiated or not; approvalId keys make replays idempotent.
if (frame.type === 'approval/requested') {
let ids = this.waitingApprovals.get(frame.sessionId)
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
if (!ids.has(frame.approvalId)) {
ids.add(frame.approvalId)
this.notifier.markDirty()
}
} else if (frame.type === 'approval/resolved') {
const ids = this.waitingApprovals.get(frame.sessionId)
if (ids !== undefined && ids.delete(frame.approvalId)) {
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
this.notifier.markDirty()
}
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question/queued frames never hit history: buffer for replay on
@@ -377,7 +425,8 @@ export class SessionManager {
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.titleSnapshots.delete(frame.sessionId)
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
return
}
case 'host/session-status': {
@@ -394,6 +443,30 @@ export class SessionManager {
}
}
/**
* The moment a connection generation dies (before any next-generation frame
* can arrive — onConnected waits for the readiness handshake while replayed
* frames flow from stream open, so clearing there would race the replay):
* drop generation-scoped live state. Approvals resolved while disconnected
* send no frame, so the stale bits and the buffered answerable frames must
* not survive into the next generation — the mux-open replay re-adds every
* still-pending question with its live rpcId.
*/
handleDisconnected(): void {
if (this.waitingApprovals.size > 0) {
this.waitingApprovals.clear()
this.notifier.markDirty()
}
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
const kept = buffer.filter(item =>
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
if (kept.length === buffer.length) continue
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
else this.pendingBuffers.set(sessionId, kept)
}
}
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
handleConnected(): void {
void this.refreshList()
@@ -402,12 +475,14 @@ export class SessionManager {
private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
const title = this.titleSnapshots.get(summary.sessionId)
return title === undefined
? summary
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
// List rows read the generic 'title' projection key (host-computed unit
// value; the bespoke session/title frame is retired).
const title = this.projectionStores.get(summary.sessionId)?.get('title')
return typeof title === 'string' && title !== ''
? { ...summary, title }
: summary
})
const fresh = flattenLineage(merged)
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
@@ -415,6 +490,7 @@ export class SessionManager {
&& prev.blank === entry.blank
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
&& prev.waitingApproval === entry.waitingApproval
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -0,0 +1,183 @@
/**
* Generic per-session projection value store (session-projection RFC, push
* model): the host is the only computation site; the client holds finished
* whole values per key — `key → { value, seq }` — seeded by the history tail
* page's projections block and updated by `session/projection` push frames,
* under the single rule **higher seq wins**. No client-side domain folding
* exists: a domain ships projection support with zero client code. Per-key
* bare observable faces feed `useProjection` (web-react binds them).
*/
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from './notifier.ts'
// The single projection type table, typed end to end (host unit, wire block,
// client store, React hook) — the interface package's pure-type outlet
// (`/types`, zero imports), never the package root: the root's dsh-agent →
// dsh-session chain would drag the host `Context.sessions` merge into the
// client program (one program must not hold both sides). No second
// client-side "views" table (user ruling, RFC Alternatives).
export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
/**
* The fifth framework hook seat (session-projection RFC): key-addressed
* projection reader delivered through the standard kit. `undefined` uniformly
* means capability absent — host unit unmounted, or no baseline/frame has
* carried the key yet. The selector overload mirrors useSession (per-key uSES
* binding; reference stability holds because a key's value reference changes
* only when a frame or baseline lands).
*/
export type UseProjection = {
<K extends Extract<keyof SessionProjectionMap, string>>(key: K): SessionProjectionMap[K] | undefined
<K extends Extract<keyof SessionProjectionMap, string>, S>(
key: K,
selector: (value: SessionProjectionMap[K] | undefined) => S,
eq?: (a: S, b: S) => boolean,
): S
}
/**
* Tail-page projections baseline — structurally identical to the wire's
* `SessionProjectionsBlock` (apiproxy api layer), restated here so the
* React-free store depends only on the type table, not the wire package's
* response vocabulary.
*/
export interface ProjectionsBaseline {
/** The consistent-cut seq (equals the window tail seq by construction). */
asOfSeq: number
/** Whole current values by key; a registered key absent here means the capability is absent. */
values: Partial<SessionProjectionMap>
}
/** One key's row: the latest finished value and the seq it is consistent with. */
interface Row {
value: unknown
seq: number
}
/** Per-key notification channel: the bare face plus its batching notifier. */
interface Channel {
face: ObservableSnapshot<unknown>
notifier: Notifier
}
/**
* One session's projection values. Framework semantics, uniform across every
* key: a baseline seeds rows at its cut, a push frame updates one row, and in
* both paths a lower-or-equal seq loses — a replayed frame cannot regress a
* value, a stale baseline cannot overwrite a newer frame. A key the store has
* never seen reads `undefined` (capability absent). Faces are identity-stable
* per key (create-on-demand, cached) so the React side binds each exactly
* once; the store-level channel (`subscribeAny`) serves coarse consumers (the
* manager's list projection reads the `title` key).
*/
export class ProjectionValueStore {
private readonly rows = new Map<string, Row>()
private readonly channels = new Map<string, Channel>()
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
private readonly anyNotifier = new Notifier(() => {})
/**
* Key-addressed bare observable face (the useProjection resolution path).
* Always defined — absence is an `undefined` snapshot, never a missing
* face, so a component may subscribe before the key ever carries a value.
* @param key - projection key.
* @returns the identity-stable face for this key.
*/
faceOf(key: string): ObservableSnapshot<unknown> {
return this.channel(key).face
}
/**
* Current whole value for a key (erased framework read; typed reads go
* through `useProjection`'s map lookup).
* @param key - projection key.
* @returns the value, or undefined while the key is absent.
*/
get(key: string): unknown {
return this.rows.get(key)?.value
}
/**
* Subscribe to any-key changes (microtask-batched) — the manager's list
* rebuild channel.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribeAny(listener: () => void): () => void {
return this.anyNotifier.subscribe(listener)
}
/**
* Apply one finished value (the `session/projection` push-frame path).
* @param key - projection key.
* @param value - whole value computed by the host unit.
* @param seq - the unit's watermark at emission.
*/
apply(key: string, value: unknown, seq: number): void {
const row = this.rows.get(key)
if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop
this.rows.set(key, { value, seq })
this.changed(key)
}
/**
* Seed from a history tail page's projections block: every carried key
* lands under the same seq rule as frames; a key the block omits is
* capability-absent as of the cut — its row clears unless a newer frame
* already superseded the cut (a stale baseline can neither overwrite nor
* clear newer values).
* @param baseline - the response's projections block.
*/
seed(baseline: ProjectionsBaseline): void {
// Erased walk: the framework crosses the open key space; per-key typing
// is re-established at the consumer (useProjection's map lookup).
const values = baseline.values as Record<string, unknown>
for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq)
for (const [key, row] of this.rows) {
if (Object.hasOwn(values, key)) continue
if (row.seq > baseline.asOfSeq) continue
this.rows.delete(key)
this.changed(key)
}
}
/**
* Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`):
* a row claiming knowledge beyond the host's own durable baseline rode
* state a restart lost — under last-wins it would wrongly outrank the
* host's recomputed (lower-seq) values forever. Durable replay and the next
* baseline re-seed whatever truly survived (the title-snapshot precedent,
* generalized).
* @param lastSeq - the subscribed frame's durable baseline seq.
*/
truncate(lastSeq: number): void {
for (const [key, row] of this.rows) {
if (row.seq <= lastSeq) continue
this.rows.delete(key)
this.changed(key)
}
}
private changed(key: string): void {
this.channels.get(key)?.notifier.markDirty()
this.anyNotifier.markDirty()
}
private channel(key: string): Channel {
let channel = this.channels.get(key)
if (channel === undefined) {
// The notifier only batches (no snapshot cache to rebuild: faces read rows directly).
const notifier = new Notifier(() => {})
channel = {
notifier,
face: {
getSnapshot: () => this.rows.get(key)?.value,
subscribe: listener => notifier.subscribe(listener),
},
}
this.channels.set(key, channel)
}
return channel
}
}

View File

@@ -0,0 +1,190 @@
/**
* The session standard-props provide channel: provider roster, bundle
* materialization (fail-loud on undeclared/missing/duplicate members), the
* static no-session projection, and the atomic current-session projection
* observable. One implementation — SessionsService drives it from wire
* truth, the test runtime's sessions double drives it from fixtures — so
* the materialization rules and the projection semantics cannot drift
* between production and the test bench.
*/
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionBinding, SessionProvideDescriptor } from './service.ts'
/** The owner-side hooks: how the channel reaches the owner's live bundles and current selection. */
export interface SessionProvideChannelHost {
/**
* Re-materialize every already-materialized bundle against the new roster
* (call {@link SessionProvideChannel.materializeInfo} per live binding).
* Lazily-materialized sessions pick the new roster up on first resolve.
*/
rebuildBundles(): void
/** Resolve the current selection's bundle (the owner's maybe-provide lookup). */
resolveCurrent(): SessionMaybeProvideInfo
}
/**
* Provider roster + materialization + current projection. The channel owns
* every rule a provider contribution must satisfy; owners keep only their
* per-session bundle storage and the definition of "current".
*/
export class SessionProvideChannel {
private readonly providers: SessionProvideDescriptor[] = []
private maybeInfoCache: SessionMaybeProvideInfo
/** Latest published current bundle (identity comparison dedupes republish). */
private currentSnapshot: SessionMaybeProvideInfo
/** Projection subscribers (plain cell: bundles hold live session sources, so no store freeze may touch them). */
private readonly listeners = new Set<() => void>()
/**
* Atomic current-session provide projection: selection changes and
* provider-roster changes publish through this one source, so a roster
* change under a stable current id republishes the bundle instead of
* stranding mounted entries.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* @param host - owner-side bundle storage and current-selection resolution.
*/
constructor(private readonly host: SessionProvideChannelHost) {
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
hooks: ['session'],
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfoCache = this.materializeMaybeInfo()
this.currentSnapshot = this.maybeInfoCache
this.currentProvideInfo = {
getSnapshot: () => this.currentSnapshot,
subscribe: (fn) => {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
},
}
}
/** The static no-session projection under the current roster (declared names present, values undefined). */
get maybeInfo(): SessionMaybeProvideInfo {
return this.maybeInfoCache
}
/**
* Register a per-session standard-props provider (see
* SessionsService.provide for the product contract). Live bundles rebuild
* immediately; misdeclared providers fail loud here, at the registration
* edge, and the registration rolls back — the channel never stays on a
* roster it cannot materialize.
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider.
*/
provide(descriptor: SessionProvideDescriptor): () => void {
this.providers.push(descriptor)
try {
this.applyRosterChange()
} catch (error) {
this.providers.splice(this.providers.indexOf(descriptor), 1)
// Restore the previous (valid) roster's bundles; cannot rethrow — the
// pre-push roster materialized successfully before.
this.applyRosterChange()
throw error
}
return () => {
const at = this.providers.indexOf(descriptor)
if (at >= 0) this.providers.splice(at, 1)
this.applyRosterChange()
}
}
/**
* Re-derive the current selection's bundle and publish it when it changed.
* Bundles are identity-stable per (scope, roster) materialization, so an
* identity compare is exact; synchronous notify — call sites (the owner's
* list subscription, provide()) already sit behind their own batching or
* registration edges.
*/
publishCurrent(): void {
const next = this.host.resolveCurrent()
if (next === this.currentSnapshot) return
this.currentSnapshot = next
for (const fn of [...this.listeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
}
/**
* Materialize the standard-props bundle for one session (fails loud on
* undeclared, missing, and duplicate member names).
* @param binding - session assembly handle fed to every resolver.
* @returns the materialized bundle (identity-stable until the next materialization).
*/
materializeInfo(binding: SessionBinding): SessionProvideInfo {
const hooks: Record<string, HostObservable<unknown>> = {}
const props: Record<string, unknown> = {}
for (const descriptor of this.providers) {
const contribution = descriptor.resolve(binding)
const contributedHooks = contribution.hooks ?? {}
const contributedProps = contribution.props ?? {}
for (const name of Object.keys(contributedHooks)) {
if (!(descriptor.hooks ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared hook "${name}"`)
}
}
for (const name of Object.keys(contributedProps)) {
if (!(descriptor.props ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared prop "${name}"`)
}
}
for (const name of descriptor.hooks ?? []) {
const source = contributedHooks[name]
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = source
}
for (const name of descriptor.props ?? []) {
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = contributedProps[name]
}
}
return {
sessionId: binding.sessionId,
hooks,
props,
// The useProjection seat: key-addressed bare value faces off the
// session's projection store (open key space — never a static roster member).
projections: { faceOf: key => binding.session.projections.faceOf(key) },
}
}
/** Rebuild the static projection and the owner's live bundles, then republish the current one. */
private applyRosterChange(): void {
this.maybeInfoCache = this.materializeMaybeInfo()
this.host.rebuildBundles()
this.publishCurrent()
}
/** Build the static no-session kit and reject duplicate declared names. */
private materializeMaybeInfo(): SessionMaybeProvideInfo {
const hooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
for (const descriptor of this.providers) {
for (const name of descriptor.hooks ?? []) {
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = undefined
}
for (const name of descriptor.props ?? []) {
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = undefined
}
}
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
}
}

View File

@@ -22,9 +22,12 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
@@ -37,6 +40,8 @@ export interface SessionSummary {
cwd?: string
parentId?: SessionId
running: boolean
/** An approval question is pending on this session (sidebar amber-dot state). */
waitingApproval: boolean
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
@@ -79,7 +84,8 @@ export class SessionCreateError extends Error {
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
readonly session: Session
/** The outward session face only — feature code never sees the concrete class. */
readonly session: SessionFace
readonly ctx: Context
}
@@ -119,6 +125,8 @@ interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
/** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
session: Session
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
provideInfo: SessionProvideInfo
}
@@ -146,7 +154,7 @@ export interface SessionProvideDescriptor {
}
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
export class SessionsService implements ISessions {
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */
@@ -170,14 +178,8 @@ export class SessionsService {
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Registered per-session standard-props providers, in registration order. */
private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
private readonly currentProvideInfoListeners = new Set<() => void>()
/** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
private readonly provideChannel: SessionProvideChannel
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
@@ -212,23 +214,17 @@ export class SessionsService {
// The current-provide projection follows the same current writes.
this.list.subscribe(() => {
this.followCurrent()
this.updateCurrentProvideInfo()
this.provideChannel.publishCurrent()
})
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
hooks: ['session'],
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfo = this.materializeMaybeProvideInfo()
this.currentProvideInfoSnapshot = this.maybeInfo
this.currentProvideInfo = {
getSnapshot: () => this.currentProvideInfoSnapshot,
subscribe: (fn) => {
this.currentProvideInfoListeners.add(fn)
return () => { this.currentProvideInfoListeners.delete(fn) }
this.provideChannel = new SessionProvideChannel({
rebuildBundles: () => {
for (const record of this.scopes.values()) {
record.provideInfo = this.provideChannel.materializeInfo(record.binding)
}
},
}
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
})
this.currentProvideInfo = this.provideChannel.currentProvideInfo
rootCtx.reflect.provide('sessions', this, undefined)
}
@@ -243,98 +239,10 @@ export class SessionsService {
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
*/
provide(descriptor: SessionProvideDescriptor): () => void {
this.providers.push(descriptor)
// Scopes may already exist (boot order: the list lands and resolves
// scopes before later plugins register) — their bundles must include
// every provider by first render, so re-materialize on roster change.
this.rematerializeProvideBundles()
return () => {
const at = this.providers.indexOf(descriptor)
if (at >= 0) this.providers.splice(at, 1)
this.rematerializeProvideBundles()
}
}
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
private rematerializeProvideBundles(): void {
this.maybeInfo = this.materializeMaybeProvideInfo()
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
this.updateCurrentProvideInfo()
}
/**
* Re-derive the current selection's provide bundle and publish it when it
* changed. Bundles are identity-stable per (scope, roster)
* materialization, so an identity compare is exact; synchronous notify —
* both call sites (list.subscribe, provide()) already sit behind their own
* batching or registration edges.
*/
private updateCurrentProvideInfo(): void {
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
if (next === this.currentProvideInfoSnapshot) return
this.currentProvideInfoSnapshot = next
for (const fn of [...this.currentProvideInfoListeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
}
/** Build the static no-session kit and reject duplicate declared names. */
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
const hooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
for (const descriptor of this.providers) {
for (const name of descriptor.hooks ?? []) {
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = undefined
}
for (const name of descriptor.props ?? []) {
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = undefined
}
}
return { sessionId: undefined, hooks, props }
}
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
const hooks: Record<string, HostObservable<unknown>> = {}
const props: Record<string, unknown> = {}
for (const descriptor of this.providers) {
const contribution = descriptor.resolve(binding)
const contributedHooks = contribution.hooks ?? {}
const contributedProps = contribution.props ?? {}
for (const name of Object.keys(contributedHooks)) {
if (!(descriptor.hooks ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared hook "${name}"`)
}
}
for (const name of Object.keys(contributedProps)) {
if (!(descriptor.props ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared prop "${name}"`)
}
}
for (const name of descriptor.hooks ?? []) {
const source = contributedHooks[name]
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = source
}
for (const name of descriptor.props ?? []) {
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = contributedProps[name]
}
}
return { sessionId: binding.sessionId, hooks, props }
// scopes before later plugins register) — the channel rebuilds their
// bundles through the host hooks so every provider lands by first render.
return this.provideChannel.provide(descriptor)
}
/**
@@ -386,6 +294,11 @@ export class SessionsService {
this.manager.handleConnected()
}
/** Drop generation-scoped live interaction state the moment a connection generation dies. */
handleDisconnected(): void {
this.manager.handleDisconnected()
}
/**
* Create a session on the host. Resolution guarantee: by the time the
* promise resolves, the created session is in the list store and
@@ -432,9 +345,9 @@ export class SessionsService {
* `agent.session`). Same service-method seam as
* {@link SessionsService.scopeOf}.
* @param ctx - an Agent-scoped context.
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
*/
sessionOf(ctx: Context): Session | undefined {
sessionOf(ctx: Context): SessionFace | undefined {
const id = scopeTagOf(ctx)
if (id === undefined) return undefined
return this.scopes.get(id)?.binding.session
@@ -466,7 +379,7 @@ export class SessionsService {
* return the static no-session projection rather than removing hook props.
*/
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.provideChannel.maybeInfo
}
/**
@@ -490,7 +403,7 @@ export class SessionsService {
* validates and the projection masks absent selections), so resolve
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.binding.session.open()
void record.session.open()
}
}
@@ -533,8 +446,9 @@ export class SessionsService {
fiber,
ctx,
binding,
session,
// Sources are bare observables; React binds selector hooks at its own seam.
provideInfo: this.materializeProvideInfo(binding),
provideInfo: this.provideChannel.materializeInfo(binding),
}
this.scopes.set(id, record)
return record
@@ -556,6 +470,7 @@ export class SessionsService {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
waitingApproval: entry.waitingApproval,
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
@@ -601,7 +516,7 @@ export class SessionsService {
void record.fiber.dispose()
// Release the Session's dispatch point with the scope it belongs to (a
// surviving instance — the live Intent — rebinds when resolve re-mints).
record.binding.session.unbindScope()
record.session.unbindScope()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)

View File

@@ -2,7 +2,7 @@
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
@@ -10,7 +10,7 @@ import type {
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type { SessionFace } from '../contract/session.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
PromptError, QueuedMessage, RunningToolCall,
@@ -20,6 +20,8 @@ import { PendingWait } from './pending.ts'
import { FoldAdapter } from './fold-adapter.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
@@ -35,6 +37,12 @@ export interface SessionOptions {
* (hidden, still reusable by connectWorkspace).
*/
onEngaged?(session: Session): void
/**
* Manager-owned projection value store to adopt (frames route through the
* manager and values outlive instantiation); omitted, the Session owns a
* private store (bare object-layer construction).
*/
projections?: ProjectionValueStore
}
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
@@ -59,9 +67,11 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer.
* snapshot. React bindings remain outside this data layer. Features see only
* the {@link SessionFace} slice (ISession verbs + the snapshot source); the
* remaining public members are manager/runtime entry points.
*/
export class Session implements ObservableSnapshot<ConversationSnapshot> {
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
@@ -99,9 +109,6 @@ 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[]>()
@@ -126,6 +133,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
private subscribedLastSeq: number | null = null
/**
* Per-session projection value store (session-projection RFC, push model):
* finished whole values computed on the host, seeded by the tail page's
* projections block and updated by `session/projection` frames under the
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
* (the useProjection resolution face); the conversation snapshot never
* carries projection values, and no client-side domain folding exists.
* Manager-owned when constructed through SessionManager (frames route and
* the store outlives instantiation, the title-snapshot precedent); a bare
* construction gets a private store.
*/
readonly projections: ProjectionValueStore
private snapshotCache: ConversationSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
@@ -149,6 +169,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private readonly api: IApiClient,
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.snapshotCache = this.buildSnapshot()
}
@@ -197,12 +218,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.notifier.markDirty()
return result
}
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt has logged
// its user/message on the host (events.length > 0 is fact, not
// optimism), while a rejected first prompt must keep the session blank
// — the client-side blank mirror only ever lowers, so flipping early on
// a failure would surface the session forever and strip its
// connectWorkspace reuse eligibility against the host's authority.
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
// conversation's first turn on the host (the host criterion — a logged
// turn/start — is fact, not optimism; standalone command and projection
// events never flip it), while a rejected first prompt must keep the
// session blank — the client-side blank mirror only ever lowers, so
// flipping early on a failure would surface the session forever and
// strip its connectWorkspace reuse eligibility against the host's
// authority.
if (this.blankBit) {
this.blankBit = false
this.options.onEngaged?.(this)
@@ -229,6 +252,21 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle;
* outcomes render as flow nodes, never as a response echo).
* @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure.
*/
async command(line: string): Promise<RpcResult<{ matched: boolean }>> {
try {
return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result
} catch (error) {
return transportError(error)
}
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()
@@ -341,13 +379,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()
@@ -482,13 +521,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = result.error
return
}
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
// 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, result.value.todos)
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
this.openState = 'open'
} catch (error) {
@@ -505,22 +544,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
* 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, todos: readonly TodoItem[] | undefined): void {
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
* A carried projections block seeds the value store (higher seq wins, so a stale
* baseline cannot overwrite a newer push frame); the window events themselves are
* never folded — the host is the only computation site. */
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): 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()
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
@@ -569,7 +604,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, result.value.todos)
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
@@ -588,7 +623,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
@@ -686,11 +721,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return
}
case 'tool/result': {
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
return
}
case 'todo/write': {
this.todos = event.data.todos
if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++
return
}
case 'turn/end': {
@@ -737,10 +768,7 @@ 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).
* 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. */
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
@@ -799,7 +827,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
queue: this.queueCache.value,
running: this.running,
composerPhase: derivePhase(
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
// Command lifecycle nodes are not conversation: running /permission
// or /plan on a fresh session keeps the hero (the client mirror of
// the host's no-turn sessionBlank predicate).
nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0,
this.promptAttempted,
),
removed: this.removed,
@@ -810,7 +841,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,
todos: this.todos,
}
}
}
@@ -821,7 +851,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* object: `hasContent` only grows within a window and `promptAttempted` is
* sticky, so blank → engaging → active never steps back; a failed first
* prompt stays engaging (retry semantics — see ComposerPhase).
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
* @param hasContent - any conversation material exists (non-command nodes,
* partial, running turn, pending waits; command lifecycle rows alone keep
* the session blank).
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/

View File

@@ -2,11 +2,13 @@
import type { Context } from 'cordis'
import type {
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsService } from '../sessions/service.ts'
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
import type { IWorkspaces } from '../contract/workspaces.ts'
import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
/** Workspace list plus the two-baseline readiness and default-target projection. */
@@ -29,8 +31,16 @@ export class WorkspaceCreateError extends Error {
}
}
/** Structured browse failure so the directory browser can branch on Host business codes. */
export class DirectoryBrowseError extends Error {
constructor(readonly rpcError: RpcError) {
super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
this.name = 'DirectoryBrowseError'
}
}
/** Real Workspace object layer and Host actions. */
export class WorkspacesService {
export class WorkspacesService implements IWorkspaces {
/** UI-facing immutable projection; the manager remains wire truth. */
readonly list: SnapshotStore<WorkspaceListState>
/** Workspace baseline and frame owner. */
@@ -43,9 +53,9 @@ export class WorkspacesService {
/**
* @param ctx - client root context.
* @param api - shared wire client.
* @param sessions - lower-level Session service used for recency and blank-session reuse.
* @param sessions - cross-domain sessions face used for recency and blank-session reuse.
*/
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) {
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
this.manager = new WorkspaceManager(api)
this.list = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'pending', error: null,
@@ -171,7 +181,7 @@ export class WorkspacesService {
}
/**
* Open the Host's native directory picker.
* Open the Host's native directory picker (the `native` capability).
* @returns the selected path, or null when the user cancelled.
*/
async pickDirectory(): Promise<string | null> {
@@ -182,6 +192,41 @@ export class WorkspacesService {
return response.result.value.path
}
/**
* List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory.
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
* @returns the level's listing with breadcrumb ancestry.
*/
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value
}
/**
* Create one child directory through the Host's `browse` capability.
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment.
* @returns the created directory's absolute path.
*/
async createDirectory(path: string, name: string): Promise<string> {
const response = await this.api.host.createDirectory({ path, name })
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value.path
}
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.
*/
async openPath(path: string): Promise<void> {
const response = await this.api.host.openPath({ path })
if (!response.result.ok) {
throw new Error(`path open failed: ${response.result.error.message}`)
}
}
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
@@ -260,7 +305,7 @@ export class WorkspacesService {
/** Stable tie-breaking follows Host Workspace order. */
function recentWorkspace(
workspaces: readonly WorkspaceView[],
sessions: ReturnType<SessionsService['list']['getSnapshot']>['byId'],
sessions: SessionsPortList['byId'],
): WorkspaceId | undefined {
let selected: WorkspaceId | undefined
let selectedTime = Number.NEGATIVE_INFINITY

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',
@@ -40,8 +65,10 @@ 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 } }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */

View File

@@ -1,8 +1,9 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
@@ -63,7 +64,7 @@ export class FakeApiClient implements IApiClient {
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[] }>> =
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
@@ -80,10 +81,25 @@ export class FakeApiClient implements IApiClient {
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -106,6 +122,9 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
@@ -133,10 +152,12 @@ export class FakeApiClient implements IApiClient {
// Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program requires-bearing catalogs and dual-address
// 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: [] }))
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
= () => 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)),
@@ -147,6 +168,15 @@ export class FakeApiClient implements IApiClient {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}
readonly goals: IApiClient['goals'] = {
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

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' } })
})
@@ -142,4 +169,83 @@ describe('FoldAdapter', () => {
const node = adapter.nodes().nodes[0]
expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
})
describe('command lifecycle nodes', () => {
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
const adapter = new FoldAdapter()
adapter.reset([
ev.user(0, '先说话'),
ev.commandRun(1, 'cmd-1', 'plan'),
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
ev.assistant(3, 0, '然后回答'),
], 0)
const { nodes } = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
expect(nodes[1]).toMatchObject({
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
it('renders a run with no done as still executing (outcome null)', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'command', name: 'goal', args: ' ship it', outcome: null,
})
})
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
outcome: { kind: 'error', text: '失败了' },
})
})
it('settles a live-appended done in place, keeping the node at the run seq', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
expect(running).toMatchObject({ outcome: null })
adapter.append(ev.commandDone(7, 'cmd-4'))
const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
// Settlement replaced the node object rather than mutating the published one.
expect(settled).not.toBe(running)
})
it('tails command nodes whose seq is past every surface node', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
})
it('command nodes survive the degraded linear-scan branch', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
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,
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)
expect(nodes.some(n => n.kind === 'command')).toBe(true)
} finally {
errorSpy.mockRestore()
}
})
})
})

View File

@@ -133,21 +133,18 @@ describe('list lifecycle', () => {
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
})
it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
rpcId: 'title-new' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
})
manager.handleMuxEnvelope({
rpcId: 'title-stale' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
})
manager.handleMuxEnvelope({
rpcId: 'title-equal' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
})
const titleFrame = (rpcId: string, title: string, seq: number) => {
manager.handleMuxEnvelope({
rpcId: rpcId as never,
payload: { type: 'session/projection', sessionId: S1, key: 'title', value: title, seq } as never,
})
}
titleFrame('title-new', 'Newest', 4)
titleFrame('title-stale', 'Stale', 3)
titleFrame('title-equal', 'Equal', 4)
api.onList = () => Promise.resolve(ok({
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
}))
@@ -155,7 +152,7 @@ describe('list lifecycle', () => {
const titled = manager.getListSnapshot()
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
expect(titled.items[0]?.title).toBe('Newest')
expect(titled.items[1]?.title).toBeUndefined()
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
@@ -163,34 +160,49 @@ describe('list lifecycle', () => {
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
})
it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// A push frame landed before the list (S2's title is newer than the block's cut).
manager.handleMuxEnvelope({
rpcId: 'push-newer' as never,
payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never,
})
api.onList = () => Promise.resolve(ok({
items: [
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
] as never[],
}))
await manager.refreshList()
const items = manager.getListSnapshot().items
// Cold row: title surfaces straight from the list block — no open, no history.
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
})
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.handleMuxEnvelope({
rpcId: 'title-unflushed' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
})
const frame = (rpcId: string, payload: object) => {
manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never })
}
frame('title-unflushed', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
manager.handleMuxEnvelope({
rpcId: 'subscribed-recovered' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
})
// The durable baseline says the host only knows up to seq 2: the phantom
// row rode lost state and must drop, or last-wins pins it forever.
frame('subscribed-recovered', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
manager.handleMuxEnvelope({
rpcId: 'title-durable' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
})
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
frame('title-durable', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 })
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
manager.handleMuxEnvelope({
rpcId: 'subscribed-current' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
})
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
// A baseline at or past the row's seq keeps it (nothing phantom to drop).
frame('subscribed-current', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
})
})
@@ -364,3 +376,62 @@ describe('connected generation', () => {
})
})
})
describe('waiting-approval list bit', () => {
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
// Mux-open replay of the same question (same approvalId) is idempotent.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
})
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
// Removed sessions drop their bit outright.
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(manager.getListSnapshot().items).toHaveLength(0)
})
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
// Generation death clears (resolved-while-disconnected questions send no frame)…
manager.handleDisconnected()
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
// …and a replayed frame arriving before onConnected (stream open precedes
// the readiness handshake) survives the later handleConnected untouched.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleConnected()
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
})
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
// Buffered pre-instantiation: an approval pair and a queued row.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
manager.handleDisconnected()
// Instantiate after the death sweep: no zombie interaction replays (the
// pendingBuffers held only dead-generation rpcIds), so the session mints
// no pending waits.
const session = manager.get(S1)
expect(session.getSnapshot().pending).toEqual([])
})
})

View File

@@ -0,0 +1,187 @@
/**
* Projection value store (session-projection RFC, push model): the single
* higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
* newer push frame; a replayed frame cannot regress), capability absence as
* undefined, generation truncation, and the Session/manager wiring (tail-page
* seeding, session/projection frame routing pre- and post-instantiation, the
* list rows' title projection).
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts'
// Test-domain keys merged into the projection map (the interface package's
// pure-type outlet), the same way domain host plugins merge theirs.
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
'test/marks': { marks: string[] }
}
}
const SID = 'fk-s1' as SessionId
describe('ProjectionValueStore semantics', () => {
it('reads undefined until a value lands (capability absence)', () => {
const store = new ProjectionValueStore()
expect(store.get('test/marks')).toBeUndefined()
expect(store.faceOf('test/marks').getSnapshot()).toBeUndefined()
})
it('applies frames last-wins by seq: replayed and stale frames drop', () => {
const store = new ProjectionValueStore()
store.apply('test/marks', { marks: ['a'] }, 5)
store.apply('test/marks', { marks: ['a', 'b'] }, 9)
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
store.apply('test/marks', { marks: ['stale'] }, 5)
store.apply('test/marks', { marks: ['equal'] }, 9)
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
})
it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => {
const store = new ProjectionValueStore()
store.apply('test/marks', { marks: ['frame-20'] }, 20)
// Stale cut: carried key loses to the newer frame; omitted key survives.
store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } })
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
store.seed({ asOfSeq: 15, values: {} })
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
// Fresh cut: carried key reseeds…
store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } })
expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
// …and an omitting fresh cut clears (capability absent as of the cut).
store.seed({ asOfSeq: 40, values: {} })
expect(store.get('test/marks')).toBeUndefined()
})
it('truncate drops rows past the durable baseline and keeps the rest', () => {
const store = new ProjectionValueStore()
store.apply('test/marks', { marks: ['durable'] }, 5)
store.apply('other', 'phantom', 50)
store.truncate(10)
expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
expect(store.get('other')).toBeUndefined()
})
it('notifies the key face on change (batched) and not on dropped applications', async () => {
const store = new ProjectionValueStore()
let keyTicks = 0
let anyTicks = 0
store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
store.subscribeAny(() => { anyTicks += 1 })
store.apply('test/marks', { marks: ['a'] }, 5)
await Promise.resolve()
expect(keyTicks).toBe(1)
expect(anyTicks).toBe(1)
store.apply('test/marks', { marks: ['replay'] }, 3)
await Promise.resolve()
expect(keyTicks).toBe(1)
expect(anyTicks).toBe(1)
})
it('faces are identity-stable per key (the React binding cache premise)', () => {
const store = new ProjectionValueStore()
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
})
})
describe('Session tail-page seeding', () => {
it('seeds the store from a history response carrying a projections block', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
} as never))
await session.open()
expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
})
it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
} as never))
await session.open()
session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9)
await session.resync()
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
})
it('treats a blockless response as no reset: pushed values survive', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
await session.open()
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
await session.resync()
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
})
})
describe('manager frame routing', () => {
const sid = (s: string): SessionId => s as SessionId
it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
rpcId: 'p1' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
})
const session = manager.get(sid('s1'))
expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
// Frames after instantiation land in the same store.
manager.handleMuxEnvelope({
rpcId: 'p2' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never,
})
expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
})
it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never)
await manager.refreshList()
manager.handleMuxEnvelope({
rpcId: 't1' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never,
})
await Promise.resolve()
expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
// The durable baseline says the host only knows up to seq 2: the row rode
// lost state and must drop (the un-flushed title precedent).
manager.handleMuxEnvelope({
rpcId: 'sub' as never,
payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never,
})
await Promise.resolve()
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
})
it('drops the projection store with the removed session', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never)
await manager.refreshList()
manager.handleMuxEnvelope({
rpcId: 't1' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never,
})
manager.handleHostEnvelope({
rpcId: 'rm' as never,
payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
})
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
})
})

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

@@ -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, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) {
function histResponse(events: SessionEvent[], hasMore = false) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } }))
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('open', () => {
@@ -104,6 +104,43 @@ describe('live event path', () => {
expect(session.getSnapshot().nodes).toEqual(before.nodes)
})
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
// Live path: run mints an executing node, done settles it in the flow.
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(6, 'cmd-live', 'plan'))
let command = session.getSnapshot().nodes.at(-1)
expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
command = session.getSnapshot().nodes.at(-1)
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
// Replay path (refresh): the same pair inside the history window folds identically.
const replayed = await opened([
...plainTurn(0, 0, 'a', 'b'),
ev.commandRun(6, 'cmd-live', 'plan'),
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
])
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => {
// A fresh session whose only window content is a command pair (plus the
// knob events a /permission switch appends — not surface-eligible, so
// they never become nodes) stays phase 'blank': selecting a preset from
// the hero must not enter the conversation view.
const { session } = await opened([])
expect(session.getSnapshot().composerPhase).toBe('blank')
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
const snapshot = session.getSnapshot()
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
expect(snapshot.composerPhase).toBe('blank')
})
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
@@ -158,42 +195,6 @@ 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')]
@@ -207,37 +208,6 @@ 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', () => {

View File

@@ -47,7 +47,7 @@ describe('list store projection', () => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'title' as never,
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2 } as never,
})
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -234,6 +234,40 @@ describe('WorkspacesService', () => {
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
await expect(workspaces.pickDirectory()).resolves.toBeNull()
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
})
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
api.onListDirectory = () => Promise.resolve(ok(listing))
await expect(workspaces.listDirectory()).resolves.toEqual(listing)
await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
// The optional path is omitted from the payload, not sent as undefined.
expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
const listFailure = workspaces.listDirectory('/x')
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
})
it('opens a filesystem path through the host without local state', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {

View File

@@ -23,6 +23,15 @@
{
"path": "../../host/apiproxy"
},
{
"path": "../../ui/commands"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../llm/llm"
},

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 packages/client/test-runtime/README.md
README.md: 883d71224139dc409229fdfb35362d040e810cc7
README.zh.md: a3daf112940b03b585d44bc5fd1317e43ebd35fe

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-client-test-runtime
English | [中文](README.zh.md)
jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic.
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3``frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only.
## Model Experience
None, as this package is browser-side test infrastructure; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production fold would never produce.

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-client-test-runtime
[English](README.md) | 中文
面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions``TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`生产面一旦改形测试台在编译期即断而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
局部 DOM 快照:`declare(children)` 注册自动 frame逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3``frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)``mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
不属于产品插件图(无 `dshClient`feature 包仅以 `devDependencies` 依赖之。
## Model Experience
无;本包是浏览器侧测试基础设施,无一物到达模型请求。
#### KV Cache effect
无;本包既不组装也不发送 provider 请求。
## Known Limitations and Deferred Work
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件可接受Node 兼容的运行时入口待出现仓外消费方再补。
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 storewire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产折叠永不产出的状态。

View File

@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-client-test-runtime",
"description": "jsdom slot test runtime: real Cordis Context + SlotsService + web-react renderer with test-owned session/workspace doubles for feature specs",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"vitest": "^4.1.8"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,82 @@
/** Session/workspace fixture shapes and snapshot defaults for the test runtime. */
import type {
ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
/**
* Fixture overrides for the session behavior face: any subset of the
* production ISession verbs (typed against it, so a face change surfaces
* here at compile time), plus extra members feature-specific casts consume.
* The open Record tail means a misnamed EXTRA member is not caught by the
* compiler (it grafts as dead weight); the ISession verbs stay safe — a
* misnamed verb leaves the fail-loud stub in place, which names itself at
* the first call.
*/
export type SessionBehaviorOverrides = Partial<ISession> & Record<string, unknown>
/**
* act-wrapped mutation runner shared by every runtime object: public mutators
* funnel through it so tests never handle SlotCore microtask batching or
* React act themselves.
*/
export type Stabilizer = (fn: () => void | Promise<void>) => Promise<void>
/**
* Session fixture accepted by {@link TestSessions.add}: identity plus optional
* snapshot/list-row overrides and the session behavior face the feature under
* test actually calls (kept open — the runtime never fakes methods a test did
* not supply, so an unstubbed call fails loud at the call site).
*/
export interface SessionFixture {
id: string
/** Overrides merged over {@link conversationSnapshot} (sessionId comes from `id`). */
snapshot?: Partial<Omit<ConversationSnapshot, 'sessionId'>>
/** List-row overrides merged over the defaults derived from `id`. */
summary?: Partial<Omit<SessionSummary, 'id'>>
/** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */
session?: SessionBehaviorOverrides
}
/**
* A complete quiescent conversation snapshot (open window, no traffic).
* @param sessionId - owning session id.
* @returns the snapshot; spread fixture overrides on top.
*/
export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
return {
sessionId,
nodes: [],
foldDegraded: false,
partial: null,
runningCalls: [],
codeDispatches: new Map(),
pending: [],
queue: [],
running: false,
composerPhase: 'active',
removed: false,
openState: 'open',
openError: null,
hasMore: false,
loadingOlder: false,
promptError: null,
blank: false,
lastAgentError: null,
}
}
/**
* A ready workspace list with no workspaces (the shape WorkspacesService
* projects after both baselines land).
* @returns the initial state of the test workspaces store.
*/
export function workspaceListState(): WorkspaceListState {
return {
items: [],
state: 'idle',
phase: 'ready',
error: null,
baselinesReady: true,
recentWorkspaceId: undefined,
}
}

View File

@@ -0,0 +1,372 @@
/**
* jsdom slot test runtime: a real small runtime — Cordis `Context`, the
* runtime `SlotsService`, and the web-react renderer — assembled around
* test-owned session/workspace doubles, so feature specs exercise
* declaration, registration, scope, store, inject, rendering, updates, and
* disposal without hand-building the machinery per suite.
*
* Not part of the product plugin graph (no `dshClient`); feature packages
* depend on it in devDependencies only. It copies no SlotCore/renderer/store
* machinery — everything mounts the production implementations.
* @module @deepseek-ai/dsh-client-test-runtime
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots):
* this compilation unit sees only the runtime's 'root' row, but consumer
* programs merge their own keys in; the rule fires on the narrow-map view. */
import { Context, Inject } from 'cordis'
import type { Fiber, Plugin } from 'cordis'
import { createElement, Fragment, useSyncExternalStore } from 'react'
import type { ReactNode } from 'react'
import { act, render, within } from '@testing-library/react'
import type { RenderResult } from '@testing-library/react'
import type { queries } from '@testing-library/dom'
import type { BoundFunctions } from '@testing-library/dom'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type {
ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
import { registerDomSnapshotSerializer } from './snapshot.ts'
import { TestSessions } from './sessions.ts'
import { TestWorkspaces } from './workspaces.ts'
import type { Stabilizer } from './fixtures.ts'
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
export { FixtureSession, TestSessions } from './sessions.ts'
export { TestWorkspaces } from './workspaces.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
/** Erased register face for the internal root call (the public declare seam holds the typing). */
type ErasedRegister = (options: object, component: unknown) => () => void
/**
* One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}:
* the `data-slot` wrapper is the snapshot root (`expect(view.container)
* .toMatchSnapshot()` captures exactly this slot's output), Testing Library
* queries are bound inside it, and `update` re-renders with new owner props.
*/
export interface SlotView<K extends keyof SlotMap & string> {
/** The `<div data-slot="<key>">` wrapper around the slot's rendered output. */
readonly container: HTMLElement
/** Testing Library queries scoped to {@link SlotView.container}. */
readonly view: BoundFunctions<typeof queries>
/**
* Replace the owner props and flush the re-render (the render-site update:
* in production the owner recomputes the share and React re-renders).
* @param owner - the next owner props share.
*/
update(owner: OwnerOf<K>): void
}
/**
* Mounted feature plugin handle: the live fiber plus an act-wrapped,
* idempotent dispose (unload cascade: entries, declared child slots, store
* instances, and provided services all fall together).
*/
export interface FeatureHandle {
/** The plugin's live Cordis fiber (state assertions, escape hatch). */
readonly fiber: Fiber
/**
* Dispose the plugin fiber inside React act; repeated calls no-op.
* @returns completion of the unload cascade.
*/
dispose(): Promise<void>
}
/**
* Owner-props cell behind the auto frame: one external store the frame
* subscribes to, so {@link SlotTestRuntime.renderSlot} and
* {@link SlotView.update} drive React through the standard uSES seam.
*/
class OwnerPropsCell {
private readonly owners = new Map<string, object>()
private readonly listeners = new Set<() => void>()
private version = 0
/** Snapshot version for uSES pairing (bumped on every set). */
readonly getVersion = (): number => this.version
/**
* Subscribe to owner-props changes.
* @param fn - change callback.
* @returns unsubscribe.
*/
readonly subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
/**
* Install or replace one key's owner props and notify (synchronous; the
* caller wraps in act).
* @param key - slot key.
* @param owner - owner props share.
*/
set(key: string, owner: object): void {
this.owners.set(key, owner)
this.version += 1
for (const fn of [...this.listeners]) fn()
}
/** Keys with supplied owner props, in first-supply order. */
entries(): readonly (readonly [string, object])[] {
return [...this.owners.entries()]
}
}
/**
* The test-owned 'root' occupant: declares the child slots a suite needs
* through the REAL `slots.register`, with a caller-supplied minimal frame —
* the runtime never guesses a feature's page structure.
*/
export class TestRoot {
private disposeEntry: (() => void) | undefined
/**
* @param slots - the runtime SlotsService.
* @param stabilize - the owning runtime's act wrapper.
*/
constructor(private readonly slots: SlotsService, private readonly stabilize: Stabilizer) {}
/**
* Register the root frame, declaring (and thereby claiming) the child
* slots. One declaration per runtime — a second call fails loud in the
* core ('root' is a single slot).
* @param children - child-slot declaration table (declaration + render authorization + runtime spec).
* @param frame - minimal frame component; its props derive from the declared keys (composed-props contract).
* @returns completion of the act-wrapped registration.
*/
async declare<const D extends ChildrenDecl>(
children: D,
frame: SlotComponent<ComposedProps<'root', keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
): Promise<void> {
await this.stabilize(() => {
// Erased hop (same pattern as SlotsService's own implementation arm);
// the declare signature above is the typed seam.
this.disposeEntry = (this.slots.register as unknown as ErasedRegister)({ name: 'root', children }, frame)
})
}
/** Remove the root registration and collapse its declarations (runtime dispose path). */
release(): void {
this.disposeEntry?.()
this.disposeEntry = undefined
}
}
/**
* The assembled test runtime. Obtain via {@link SlotTestRuntime.create};
* dispose with {@link SlotTestRuntime.dispose} (afterEach). Public mutators
* are act-wrapped throughout — tests never handle SlotCore microtask
* batching or React act themselves.
*/
export class SlotTestRuntime {
/** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */
readonly ctx: Context
/** The production SlotsService mounted on {@link SlotTestRuntime.ctx}. */
readonly slots: SlotsService
/** The test-owned 'root' occupant. */
readonly root: TestRoot
/** Sessions double (list/current observable, cells, scopes, behavior faces). */
readonly sessions: TestSessions
/** Workspaces double (list observable, recorded intent actions). */
readonly workspaces: TestWorkspaces
private readonly stabilizer: Stabilizer = async (fn) => {
await act(async () => { await fn() })
}
private host: SlotRendererHost | undefined
private readonly views: RenderResult[] = []
private readonly handles: FeatureHandle[] = []
private disposed = false
/** Auto-frame state ({@link SlotTestRuntime.declare} / {@link SlotTestRuntime.renderSlot}). */
private readonly ownerCell = new OwnerPropsCell()
private readonly autoDeclared = new Set<string>()
private autoRootView: RenderResult | undefined
private constructor(ctx: Context, slots: SlotsService) {
this.ctx = ctx
this.slots = slots
this.root = new TestRoot(slots, this.stabilizer)
this.sessions = new TestSessions(this.stabilizer, ctx)
this.workspaces = new TestWorkspaces(this.stabilizer)
ctx.provide('sessions', this.sessions)
ctx.provide('workspaces', this.workspaces)
// Capturing install: the production renderer does the rendering; the
// wrapper only takes the host face for storeOf (no machinery copied).
const renderer = createSlotRenderer()
slots.install({
renderRoot: (host, ownerProps) => {
this.host = host
return renderer.renderRoot(host, ownerProps)
},
})
}
/**
* Assemble a runtime: real Context, mounted SlotsService, installed
* renderer, and the session/workspace doubles provided as services.
* @returns the ready runtime.
*/
static async create(): Promise<SlotTestRuntime> {
registerDomSnapshotSerializer()
const ctx = new Context()
const fiber = ctx.plugin(SlotsService)
await fiber.await()
return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService)
}
/**
* Provide an extra service the feature under test injects (e.g. a layout
* fake). Sugar over `ctx.provide`, typed against the Context declaration
* merge: for a declared service name the fake must be a subset of that
* service's outward face (Partial — supply only what the feature calls),
* so a production face change breaks the fake at compile time. Undeclared
* names stay unchecked (ad-hoc test services).
* @param name - service name.
* @param value - service implementation (test double).
*/
provide<K extends string>(name: K, value: K extends keyof Context ? Partial<Context[K]> : unknown): void {
this.ctx.provide(name, value)
}
/**
* Mount a feature plugin on a real fiber. Required services are prechecked
* so a missing provider fails loud instead of suspending the fiber forever
* (deliberate load-order suspension tests use `ctx.plugin` directly).
* @param plugin - plugin value (function, class, or `{ inject, apply }` object).
* @returns handle owning the fiber's explicit disposal.
*/
async mount(plugin: Plugin): Promise<FeatureHandle> {
const required = Object.keys(Inject.resolve((plugin as { inject?: Inject }).inject))
const missing = required.filter(name => this.ctx.get(name) === undefined)
if (missing.length > 0) {
throw new Error(`mount would suspend: missing service(s) ${missing.join(', ')} — provide() them first`)
}
const fiber = this.ctx.plugin(plugin)
await this.stabilizer(async () => {
await fiber.await()
})
let disposed = false
const handle: FeatureHandle = {
fiber,
dispose: async () => {
if (disposed) return
disposed = true
await this.stabilizer(() => fiber.dispose())
},
}
this.handles.push(handle)
return handle
}
/**
* Render the root slot tree through the ctx-level entry (the shell's own
* seam): `ctx.slots.renderSlot('root', {})` under Testing Library.
* @returns the Testing Library view.
*/
renderRoot(): RenderResult {
const view = render(createElement(Fragment, null, this.slots.renderSlot('root', {})))
this.views.push(view)
return view
}
/**
* Declare child slots under an auto-generated root frame — the single-slot
* mounting path for local DOM snapshots. Each key later supplied through
* {@link SlotTestRuntime.renderSlot} renders inside its own
* `<div data-slot="<key>">` wrapper (the snapshot root). Mutually exclusive
* with {@link TestRoot.declare} ('root' is a single slot); one call per
* runtime.
* @param children - child-slot declaration table (same contract as TestRoot.declare).
* @returns completion of the act-wrapped registration.
*/
async declare(children: ChildrenDecl): Promise<void> {
for (const key of Object.keys(children)) this.autoDeclared.add(key)
const cell = this.ownerCell
const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => {
useSyncExternalStore(cell.subscribe, cell.getVersion)
return createElement(Fragment, null, cell.entries().map(([key, owner]) =>
createElement('div', { 'data-slot': key, key }, props.renderSlot(key, owner))))
}
await this.root.declare(children as never, AutoFrame as never)
}
/**
* Render one declared slot with its owner props and return the local view.
* The whole root tree mounts through the production assembly path
* (renderer, scope providers, store axis); only this key's output lands in
* the returned container. Call again with another key to view a sibling
* slot of the same tree.
* @param key - a key declared through {@link SlotTestRuntime.declare}.
* @param owner - owner props share for the render site.
* @returns the slot-local view (snapshot container, scoped queries, owner updates).
*/
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): SlotView<K> {
if (!this.autoDeclared.has(key)) {
throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`)
}
const install = (next: object): void => {
// Synchronous cell write inside act: the frame re-renders through uSES.
act(() => {
this.ownerCell.set(key, next)
})
}
install(owner)
this.autoRootView ??= this.renderRoot()
const container = this.autoRootView.container.querySelector(`[data-slot="${key}"]`)
if (!(container instanceof HTMLElement)) {
throw new Error(`renderSlot('${key}'): the auto frame rendered no wrapper — was the runtime already disposed?`)
}
return { container, view: within(container), update: install }
}
/**
* Resolve the store instance the renderer would hand a slot's component
* (identity assertions, action-driven writes). Requires a prior
* {@link SlotTestRuntime.renderRoot} — the host face exists only inside the
* installed renderer, exactly as in production.
* @param key - slot key whose first entry declares the store.
* @param scopeKey - session id for session-scope slots; omit for root scope.
* @returns the live store instance.
*/
storeOf(key: keyof SlotMap & string, scopeKey?: string): StoreInstanceLike {
if (this.host === undefined) {
throw new Error('storeOf before renderRoot() — the host face exists only inside the installed renderer')
}
const entry = this.host.entriesOf(key)[0]
if (entry === undefined) throw new Error(`storeOf('${key}'): no registration on the ledger`)
const instance = this.host.storeOf(entry, scopeKey)
if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`)
return instance
}
/**
* Flush pending ledger/store notifications inside act — for mutations made
* outside the runtime's own methods (e.g. a direct `slots.register`).
* @returns completion of the act pass.
*/
async flush(): Promise<void> {
await this.stabilizer(() => {})
}
/**
* Tear down: unmount React trees first, then dispose feature fibers, the
* root registration, minted session scopes, and persisted test state.
* Idempotent.
* @returns completion of the teardown.
*/
async dispose(): Promise<void> {
if (this.disposed) return
this.disposed = true
this.autoRootView = undefined
for (const view of this.views.splice(0)) view.unmount()
for (const handle of this.handles.splice(0)) await handle.dispose()
this.root.release()
await this.sessions.disposeScopes()
localStorage.clear()
}
}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-test-runtime`.
* @module @deepseek-ai/dsh-client-test-runtime/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime'
/** Cordis companion plugin name. */
export const name = 'client-test-runtime-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this test-support package owns no production event
* stream or mutable data — it assembles the runtime SlotsService and renderer
* (whose packages own their invariants) around test doubles; its own behavior
* is exercised by its package tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,399 @@
/** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */
import type { Context } from 'cordis'
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import { conversationSnapshot } from './fixtures.ts'
import type { SessionFixture, Stabilizer } from './fixtures.ts'
/**
* The fixture-backed session face: conversation reads delegate to the
* fixture's snapshot store; ISession verbs are fail-loud stubs unless the
* fixture supplies them (the runtime never fakes behavior a test did not
* declare — an unstubbed call names itself instead of half-working). Extra
* fixture methods are grafted verbatim for feature-side casts.
*/
export class FixtureSession implements SessionFace {
/**
* The useProjection seat: identity-stable per-key faces over the fixture's
* projection values (set via {@link TestSessions.setProjection}).
*/
readonly projections: ProjectionsFace & { set(key: string, value: unknown): void }
/**
* @param sessionId - host identity (branded view of the fixture id).
* @param store - conversation snapshot store (updateSnapshot writes it).
* @param overrides - fixture-declared behavior face, grafted over the stubs.
*/
constructor(
readonly sessionId: SessionId,
private readonly store: SnapshotStore<ConversationSnapshot>,
overrides: Record<string, unknown>,
) {
const values = new Map<string, unknown>()
const listeners = new Map<string, Set<() => void>>()
const faces = new Map<string, ObservableSnapshot<unknown>>()
this.projections = {
faceOf: (key: string) => {
let face = faces.get(key)
if (face === undefined) {
face = {
getSnapshot: () => values.get(key),
subscribe: (fn: () => void) => {
const set = listeners.get(key) ?? new Set()
set.add(fn)
listeners.set(key, set)
return () => { set.delete(fn) }
},
}
faces.set(key, face)
}
return face
},
set: (key: string, value: unknown) => {
values.set(key, value)
for (const fn of [...(listeners.get(key) ?? [])]) fn()
},
}
Object.assign(this, overrides)
}
/** @returns the fixture conversation snapshot (useSession read side). */
getSnapshot(): ConversationSnapshot {
return this.store.getSnapshot()
}
/**
* Subscribe to fixture snapshot changes.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void {
return this.store.subscribe(fn)
}
/**
* Fail-loud stub; supply `prompt` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
prompt(): never {
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
cancel(): never {
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `command` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
command(): never {
throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
loadOlder(): never {
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
}
}
/** One live test session: fixture-derived stores plus its minted scope state. */
interface SessionRecord {
summary: SessionSummary
snapshot: SnapshotStore<ConversationSnapshot>
session: FixtureSession
scope: Context | undefined
scopeFiber: { dispose(): Promise<void> } | undefined
/** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */
provideInfo: SessionProvideInfo | undefined
}
/** Test binding shape handed to provider resolvers and feature injects (a SessionBinding whose session is the fixture face). */
export interface TestSessionBinding {
readonly sessionId: SessionId
readonly session: FixtureSession
readonly ctx: Context
}
/**
* Sessions test double behind the renderer host and feature injects: owns the
* list/current observable, the standard-props provide channel (the runtime's
* `useSession` contribution included), scope minting through the production
* `createScope`, and the session behavior face supplied per fixture.
*
* Implements the same ISessions face features receive as `ctx.sessions`, so
* a production face change breaks this double at compile time; the extra
* members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
* legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
*/
export class TestSessions implements ISessions {
/** The useSessions standard feed (list rows + current selection). */
readonly list: SnapshotStore<SessionListState>
/**
* Atomic current-session provide projection (production SessionsService
* mirror): selection changes and provider-roster changes publish through
* this one source — the member the SlotsService host face hands the
* renderer's SessionProvider.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
private readonly records = new Map<SessionId, SessionRecord>()
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face (open/clear), newest last. */
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
/**
* @param stabilize - the owning runtime's act wrapper.
* @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
*/
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
})
this.channel = new SessionProvideChannel({
rebuildBundles: () => {
for (const record of this.records.values()) {
if (record.provideInfo !== undefined) {
record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record))
}
}
},
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
})
this.currentProvideInfo = this.channel.currentProvideInfo
// The projection follows every current write, as in production.
this.list.subscribe(() => { this.channel.publishCurrent() })
}
/**
* Add a session from a fixture and (by default) make it current.
* @param fixture - identity + snapshot/summary overrides + behavior face.
* @param opts - pass `current: false` to add without selecting.
* @returns the stable session id (branded view of `fixture.id`).
*/
async add(fixture: SessionFixture, opts?: { current?: boolean }): Promise<SessionId> {
const id = fixture.id as SessionId
if (this.records.has(id)) throw new Error(`test session "${id}" already added`)
const summary: SessionSummary = {
id,
displayTitle: fixture.id,
running: false,
waitingApproval: false,
blank: false,
updatedAt: this.records.size + 1,
...fixture.summary,
}
const snapshot = createSnapshotStore<ConversationSnapshot>({
...conversationSnapshot(id),
...fixture.snapshot,
})
this.records.set(id, {
summary,
snapshot,
session: new FixtureSession(id, snapshot, fixture.session ?? {}),
scope: undefined,
scopeFiber: undefined,
provideInfo: undefined,
})
await this.stabilize(() => {
this.list.update((draft) => {
draft.ids.push(id)
draft.byId[id] = summary
if (opts?.current !== false) draft.current = id
})
})
return id
}
/**
* Update a session's conversation snapshot through an immer draft (the
* live-stream stand-in: components subscribed via useSession re-render).
* @param id - session id.
* @param mutate - draft mutator.
*/
async updateSnapshot(id: string, mutate: (draft: ConversationSnapshot) => void): Promise<void> {
const record = this.require(id)
await this.stabilize(() => { record.snapshot.update(mutate) })
}
/**
* Switch the current selection (undefined = the no-session empty state).
* @param id - session id to select, or undefined to clear.
*/
async setCurrent(id: string | undefined): Promise<void> {
if (id !== undefined) this.require(id)
await this.stabilize(() => {
this.list.update((draft) => { draft.current = id as SessionId | undefined })
})
}
/**
* Remove a session: list row, scope fiber, and per-session store instances
* (with persisted state) die together — the same single lifecycle axis the
* production SessionsService drives on session death, minus staging.
* @param id - session id.
*/
async remove(id: string): Promise<void> {
const record = this.require(id)
this.records.delete(id as SessionId)
await this.stabilize(async () => {
this.list.update((draft) => {
draft.ids = draft.ids.filter(existing => existing !== id)
const { [id as SessionId]: _dead, ...rest } = draft.byId
draft.byId = rest
if (draft.current === id) draft.current = undefined
})
if (record.scopeFiber !== undefined) await record.scopeFiber.dispose()
this.rootCtx.get('slots')?.pruneStoreScope(id)
})
}
/**
* Register a per-session standard-props provider (production `provide`
* contract: hooks become `use<Name>` selector hooks on the render side,
* props spread verbatim; duplicate names fail loud at materialization).
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider.
*/
provide(descriptor: SessionProvideDescriptor): () => void {
return this.channel.provide(descriptor)
}
/**
* Resolve the definite per-session standard-props bundle (host face member).
* @param id - session id.
* @returns the identity-stable bundle, or undefined for unknown sessions.
*/
provideInfo(id: string): SessionProvideInfo | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id as SessionId, record))
return record.provideInfo
}
/**
* Resolve the current-session-optional standard kit (host face member):
* unknown or absent ids return the static no-session projection.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.channel.maybeInfo
}
/**
* Resolve (mint on first touch) the session-scoped Cordis context through
* the production `createScope`, so real `scopeOf`/scope-addressed services
* resolve it.
* @param id - session id.
* @returns the scoped context, or undefined for unknown sessions.
*/
scope(id: string): Context | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
if (record.scope === undefined) {
const handle = createScope(this.rootCtx, id as SessionId)
record.scope = handle.ctx
record.scopeFiber = handle.fiber
}
return record.scope
}
/**
* Session assembly binding (inject factories and provide resolvers receive it).
* @param id - session id.
* @returns sessionId + behavior face + scoped ctx, or undefined when unknown.
*/
binding(id: string): TestSessionBinding | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
return this.bindingOf(id as SessionId, record)
}
/**
* Read the session scope tag off a context (service-method seam mirror).
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeOf(ctx)
}
/**
* Resolve the scoped session face off a context (production `sessionOf`
* mirror).
* @param ctx - any client context.
* @returns the fixture session face, or undefined off-scope.
*/
sessionOf(ctx: Context): SessionFace | undefined {
const id = scopeOf(ctx)
if (id === undefined) return undefined
return this.records.get(id)?.session
}
/**
* Service-level selection call (recorded, then applied to the list store
* synchronously — inject callbacks call this outside any act window; the
* store notify is microtask-batched so the next stabilized step observes it).
* @param id - session id.
*/
open(id: SessionId): void {
this.calls.push({ method: 'open', args: [id] })
this.require(id)
this.list.update((draft) => { draft.current = id })
}
/** Clear the current selection (recorded; the production no-session flow). */
clear(): void {
this.calls.push({ method: 'clear', args: [] })
this.list.update((draft) => { draft.current = undefined })
}
/**
* The session face of a fixture (typed view for assertions; fixture
* behavior methods are grafted onto it).
* @param id - session id.
* @returns the FixtureSession the binding and provide channel carry.
*/
behavior(id: string): FixtureSession {
return this.require(id).session
}
/** Dispose minted scope fibers (runtime dispose path). */
async disposeScopes(): Promise<void> {
for (const record of this.records.values()) {
if (record.scopeFiber !== undefined) {
await record.scopeFiber.dispose()
record.scope = undefined
record.scopeFiber = undefined
}
}
}
private bindingOf(id: SessionId, record: SessionRecord): TestSessionBinding {
const ctx = this.scope(id)
/* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope
* always resolves; kept so a future caller cannot mint a ctx-less binding. */
if (ctx === undefined) throw new Error(`test session "${id}" resolved no scope`)
return { sessionId: id, session: record.session, ctx }
}
private require(id: string): SessionRecord {
const record = this.records.get(id as SessionId)
if (record === undefined) throw new Error(`test session "${id}" is not added`)
return record
}
}

View File

@@ -0,0 +1,89 @@
/**
* DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
* files structural. Two normalizations, both on a clone (the live DOM is
* untouched, so class/tag queries keep working):
*
* - CSS-module scoped class names (`_frame_334d2d`, this repo's
* `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so
* CSS edits do not churn snapshots.
* - `<svg>` internals collapse to a `data-content` fingerprint on the svg
* element: path geometry is print noise, but the fingerprint still flips
* when an icon's artwork actually changes.
*/
import { expect } from 'vitest'
import type { SnapshotSerializer } from 'vitest'
/** One scoped class token: `_<local>_<hash>` (local may itself contain underscores). */
const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/
/** Fold scoped tokens in one class attribute value; foreign tokens pass through. */
function normalizeClassValue(value: string): string {
return value
.split(/\s+/)
.filter(token => token !== '')
.map(token => token.replace(SCOPED_CLASS, '$1'))
.join(' ')
}
/** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */
function fingerprint(markup: string): string {
let hash = 0x811c9dc5
for (let i = 0; i < markup.length; i++) {
hash ^= markup.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
/** svg elements of a subtree, the root included when it is one. */
function svgsOf(root: Element): Element[] {
const svgs: Element[] = [...root.querySelectorAll('svg')]
if (root.tagName.toLowerCase() === 'svg') svgs.unshift(root)
return svgs
}
/** Whether serializing this subtree needs a normalized clone. */
function needsNormalization(root: Element): boolean {
const scoped = [root, ...root.querySelectorAll('[class]')].some((el) => {
const value = el.getAttribute('class')
return value !== null && value.split(/\s+/).some(token => SCOPED_CLASS.test(token))
})
return scoped || svgsOf(root).some(svg => svg.childNodes.length > 0)
}
/**
* The serializer plugin. Matches DOM elements whose subtree carries a scoped
* class or svg internals; serializes a normalized clone, which no longer
* matches, so printing falls through to the built-in DOM element serializer.
*/
export const domSnapshotSerializer: SnapshotSerializer = {
test(value: unknown): boolean {
return typeof Element !== 'undefined' && value instanceof Element && needsNormalization(value)
},
serialize(value, config, indentation, depth, refs, printer): string {
const clone = (value as Element).cloneNode(true) as Element
for (const el of [clone, ...clone.querySelectorAll('[class]')]) {
const raw = el.getAttribute('class')
if (raw !== null) el.setAttribute('class', normalizeClassValue(raw))
}
for (const svg of svgsOf(clone)) {
if (svg.childNodes.length === 0) continue
svg.setAttribute('data-content', fingerprint(svg.innerHTML))
svg.replaceChildren()
}
return printer(clone, config, indentation, depth, refs)
},
}
let registered = false
/**
* Register {@link domSnapshotSerializer} with vitest's expect (idempotent).
* SlotTestRuntime.create() calls this; specs that snapshot DOM outside the
* runtime import and call it themselves.
*/
export function registerDomSnapshotSerializer(): void {
if (registered) return
registered = true
expect.addSnapshotSerializer(domSnapshotSerializer)
}

View File

@@ -0,0 +1,189 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { workspaceListState } from './fixtures.ts'
import type { Stabilizer } from './fixtures.ts'
/**
* Workspaces test double. Implements the same IWorkspaces face features
* receive as `ctx.workspaces`, so a production face change breaks this
* double at compile time. Every action records into {@link
* TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
* richer behavior replace them via {@link TestWorkspaces.stub}.
*/
export class TestWorkspaces implements IWorkspaces {
/** The useWorkspaces standard feed. */
readonly list: SnapshotStore<WorkspaceListState>
/** Calls observed on the action face, newest last. */
readonly calls: { method: string; args: unknown[] }[] = []
/** Replaceable action seat: feature tests may stub richer behavior. */
private readonly stubs = new Map<string, (...args: unknown[]) => unknown>()
/**
* @param stabilize - the owning runtime's act wrapper.
*/
constructor(private readonly stabilize: Stabilizer) {
this.list = createSnapshotStore<WorkspaceListState>(workspaceListState())
}
/**
* Update the workspace list state through an immer draft.
* @param mutate - draft mutator.
*/
async update(mutate: (draft: WorkspaceListState) => void): Promise<void> {
await this.stabilize(() => { this.list.update(mutate) })
}
/**
* Replace an action's behavior (the recorded call is still appended first).
* @param method - action name (e.g. 'connectWorkspace').
* @param impl - replacement behavior.
*/
stub(method: string, impl: (...args: unknown[]) => unknown): void {
this.stubs.set(method, impl)
}
/**
* Connect a workspace to its reusable/new blank session (recorded). The
* default resolves the workspace id back as the session id; stub for
* cross-session flows.
* @param workspaceId - target workspace.
* @returns the connected session id.
*/
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
this.calls.push({ method: 'connectWorkspace', args: [workspaceId] })
const stub = this.stubs.get('connectWorkspace')
if (stub !== undefined) return await (stub(workspaceId) as Promise<SessionId>)
return `session-of-${workspaceId}` as SessionId
}
/**
* New-session flow (recorded; stubbed behavior runs when installed).
* @param workspaceId - optional explicit workspace target.
*/
startSession(workspaceId?: WorkspaceId): void {
this.calls.push({ method: 'startSession', args: [workspaceId] })
this.stubs.get('startSession')?.(workspaceId)
}
/**
* Create a Workspace (recorded). The default echoes a view derived from
* the input; stub for failure or list-coupled flows.
* @param input - exactly one Host create spelling.
* @returns the created Workspace view.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
this.calls.push({ method: 'create', args: [input] })
const stub = this.stubs.get('create')
if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
const title = 'name' in input ? input.name : input.path
return {
workspaceId: `ws-${title}` as WorkspaceId,
title,
path: 'path' in input ? input.path : `/${input.name}`,
sessionIds: [],
} as unknown as WorkspaceView
}
/**
* Open a path with the host OS default application (recorded; default no-op).
* @param path - host-resolvable path.
*/
async openPath(path: string): Promise<void> {
this.calls.push({ method: 'openPath', args: [path] })
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
}
/**
* Directory picker (recorded). The default cancels (null); stub to select.
* @returns the picked path, or null.
*/
async pickDirectory(): Promise<string | null> {
this.calls.push({ method: 'pickDirectory', args: [] })
const stub = this.stubs.get('pickDirectory')
if (stub !== undefined) return await (stub() as Promise<string | null>)
return null
}
/**
* Browse listing (recorded). The default serves an empty home level; stub
* to shape a tree.
* @param path - absolute directory to list; absent lists the home level.
* @returns the level's listing.
*/
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
// The signal is recorded and forwarded like the production face passes
// it to the wire, so cancellation integration tests can observe or
// reject on a superseded scan.
this.calls.push({ method: 'listDirectory', args: [path, signal] })
const stub = this.stubs.get('listDirectory')
if (stub !== undefined) return await (stub(path, signal) as Promise<DirectoryListing>)
// The chain runs root-to-target inclusive, per the DirectoryListing
// contract — a bare root crumb would mislabel the level in browsers
// driven by this double.
return {
path: '/home/test',
home: '/home/test',
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'test', path: '/home/test', hidden: false },
],
entries: [],
truncated: false,
}
}
/**
* Browse child creation (recorded). The default joins parent and name.
* @param path - absolute existing parent directory.
* @param name - single path segment.
* @returns the created directory's absolute path.
*/
async createDirectory(path: string, name: string): Promise<string> {
this.calls.push({ method: 'createDirectory', args: [path, name] })
const stub = this.stubs.get('createDirectory')
if (stub !== undefined) return await (stub(path, name) as Promise<string>)
return `${path}/${name}`
}
/**
* Rename a Workspace (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace.
* @param title - new title.
* @returns the updated view.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
this.calls.push({ method: 'rename', args: [workspaceId, title] })
const stub = this.stubs.get('rename')
if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
}
/**
* Delete a Workspace (recorded; default no-op).
* @param workspaceId - target workspace.
*/
async delete(workspaceId: WorkspaceId): Promise<void> {
this.calls.push({ method: 'delete', args: [workspaceId] })
await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
}
/**
* Move an accounted session (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace.
* @param sessionId - session to move.
* @param beforeSessionId - anchor; omitted appends.
* @returns the updated view.
*/
async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
const stub = this.stubs.get('insertSessionBefore')
if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
}
}

View File

@@ -0,0 +1,37 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`single-slot mounting (declare + renderSlot) > folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone 1`] = `
<div
data-slot="trt.panel"
>
<div
class="frame plain"
>
<span
class="label"
>
styled
</span>
<svg
aria-hidden="true"
data-content="2bfa09dc"
viewBox="0 0 16 16"
/>
</div>
</div>
`;
exports[`single-slot mounting edge arms > serializes childless svg untouched next to scoped classes 1`] = `
<div
data-slot="trt.panel"
>
<div
class="frame"
>
<svg
aria-hidden="true"
viewBox="0 0 1 1"
/>
</div>
</div>
`;

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(TestRuntimeInvariant).await()).resolves.toBeDefined()
})
})

View File

@@ -0,0 +1,573 @@
// @vitest-environment jsdom
/**
* SlotTestRuntime behavior: root declaration + rendering, session
* add/update/switch/remove through the real renderer, shared store identity
* and scope pruning, feature mount/dispose cascade, and runtime disposal
* idempotence. All through the production SlotsService + createSlotRenderer
* stack — this suite is the fixture the migrated feature specs rely on.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } }
'trt.chat': { kind: 'single'; scope: 'session' }
'trt.rows': { kind: 'list'; scope: 'root' }
}
}
afterEach(cleanup)
type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'>
/** Root frame declaring all three suite slots (render sites for each kind). */
function Frame({ renderSlot, SessionProvider }: FrameProps) {
return (
<>
{renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
<SessionProvider empty={() => <i>no session</i>}>
{() => renderSlot('trt.chat', {})}
</SessionProvider>
{renderSlot('trt.rows', {})}
</>
)
}
const CHILDREN = {
'trt.panel': { kind: 'single', scope: 'root' },
'trt.chat': { kind: 'single', scope: 'session' },
'trt.rows': { kind: 'list', scope: 'root' },
} as const
async function runtimeWithFrame() {
const runtime = await SlotTestRuntime.create()
await runtime.root.declare(CHILDREN, Frame)
return runtime
}
describe('root declaration and rendering', () => {
it('renders declared slots through the real renderer: fallback, then a live registration, then unload', async () => {
const runtime = await runtimeWithFrame()
const view = runtime.renderRoot()
expect(view.container.textContent).toContain('no panel')
let dispose = (): void => {}
await runtime.flush() // no-op guard: flush outside mutations is safe
await (async () => {
dispose = runtime.slots.register(
{ name: 'trt.panel' },
({ label }: { label?: string }) => <b>panel:{label}</b>)
await runtime.flush()
})()
expect(view.container.textContent).toContain('panel:from-owner')
dispose()
await runtime.flush()
expect(view.container.textContent).toContain('no panel')
await runtime.dispose()
})
it('fails loud when rendering with no root declaration (production boot-order check)', async () => {
const runtime = await SlotTestRuntime.create()
expect(() => runtime.renderRoot()).toThrow(/'root' has no registration/)
await runtime.dispose()
})
})
describe('sessions', () => {
it('drives SessionProvider: empty state, current session, switch, live snapshot updates', async () => {
const runtime = await runtimeWithFrame()
runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => {
const running = props.useSession(s => s.running)
return <span>chat:{props.sessionId}:{String(running)}</span>
})
const view = runtime.renderRoot()
expect(view.container.textContent).toContain('no session')
await runtime.sessions.add({ id: 's1' })
expect(view.container.textContent).toContain('chat:s1:false')
await runtime.sessions.updateSnapshot('s1', (draft) => { draft.running = true })
expect(view.container.textContent).toContain('chat:s1:true')
await runtime.sessions.add({ id: 's2' }) // becomes current by default
expect(view.container.textContent).toContain('chat:s2:false')
await runtime.sessions.setCurrent(undefined)
expect(view.container.textContent).toContain('no session')
await runtime.sessions.setCurrent('s1')
expect(view.container.textContent).toContain('chat:s1:true')
await runtime.dispose()
})
it('add with current:false keeps the selection; unknown ids fail loud on the mutators', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' }, { current: false })
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2'])
await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/)
await expect(runtime.sessions.setCurrent('ghost')).rejects.toThrow(/not added/)
await expect(runtime.sessions.updateSnapshot('ghost', () => {})).rejects.toThrow(/not added/)
await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/)
expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/)
await runtime.dispose()
})
it('mints REAL-tag scopes lazily and resolves them through the production scopeOf; bindings expose the behavior face', async () => {
const runtime = await runtimeWithFrame()
const prompt = vi.fn()
await runtime.sessions.add({ id: 's1', session: { prompt } })
expect(runtime.sessions.provideInfo('ghost')).toBeUndefined()
expect(runtime.sessions.scope('ghost')).toBeUndefined()
expect(runtime.sessions.binding('ghost')).toBeUndefined()
const scope = runtime.sessions.scope('s1')!
expect(runtime.sessions.scope('s1')).toBe(scope) // stable per session
expect(runtime.sessions.scopeOf(scope)).toBe('s1')
expect(runtime.sessions.scopeOf(runtime.ctx)).toBeUndefined()
// sessionOf resolves the behavior face off the scope tag.
expect(runtime.sessions.sessionOf(scope)).toBe(runtime.sessions.behavior('s1'))
expect(runtime.sessions.sessionOf(runtime.ctx)).toBeUndefined()
const binding = runtime.sessions.binding('s1')!
expect(binding.sessionId).toBe('s1')
expect(binding.ctx).toBe(scope)
;(binding.session as { prompt: () => void }).prompt()
expect(prompt).toHaveBeenCalledOnce()
expect(runtime.sessions.behavior('s1')).toBe(binding.session)
// The binding's session doubles as the conversation observable face.
expect((binding.session as { getSnapshot(): { sessionId: string } }).getSnapshot().sessionId).toBe('s1')
// A scoped service resolves through the scope ctx (scope-addressed pattern).
runtime.provide('probe', { hello: 'world' })
expect(scope.get('probe')).toEqual({ hello: 'world' })
await runtime.dispose()
})
it('materializes provide bundles: built-in session hook, custom providers, no-session projection', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
const info = runtime.sessions.provideInfo('s1')!
expect(info.sessionId).toBe('s1')
expect(info.hooks['session']).toBeDefined() // the built-in useSession source
expect(runtime.sessions.provideInfo('s1')).toBe(info) // identity-stable
// A feature provider (the ui-conversation input pattern): declared names
// materialize per session and land in the no-session roster as undefined.
const off = runtime.sessions.provide({
hooks: ['probe'],
props: ['probeActions'],
resolve: binding => ({
hooks: { probe: { getSnapshot: () => binding.sessionId, subscribe: () => () => {} } },
props: { probeActions: { poke: () => {} } },
}),
})
const enriched = runtime.sessions.provideInfo('s1')!
expect(enriched.hooks['probe']?.getSnapshot()).toBe('s1')
expect(enriched.props['probeActions']).toBeDefined()
const maybe = runtime.sessions.maybeProvideInfo(undefined)
expect(maybe.sessionId).toBeUndefined()
expect(Object.keys(maybe.hooks)).toEqual(['session', 'probe'])
expect(runtime.sessions.maybeProvideInfo('s1')).toBe(runtime.sessions.provideInfo('s1'))
expect(runtime.sessions.maybeProvideInfo('ghost').sessionId).toBeUndefined()
// Misdeclared providers fail loud AT REGISTRATION (the production
// channel rebuilds live bundles eagerly and rolls the roster back):
// missing hook, missing prop, duplicate hook, duplicate prop.
expect(() => runtime.sessions.provide({ hooks: ['void'], resolve: () => ({}) }))
.toThrow(/missing hook "void"/)
expect(() => runtime.sessions.provide({ props: ['void'], resolve: () => ({}) }))
.toThrow(/missing prop "void"/)
expect(() => runtime.sessions.provide({
hooks: ['session'],
resolve: () => ({ hooks: { session: { getSnapshot: () => 0, subscribe: () => () => {} } } }),
})).toThrow(/duplicate hook "session"/)
const propA = runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 1 } }) })
expect(() => runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 2 } }) }))
.toThrow(/duplicate prop "twice"/)
propA()
// The rejected registrations rolled back: the roster still materializes.
expect(runtime.sessions.provideInfo('s1')).toBeDefined()
off()
off() // disposer is idempotent
expect(Object.keys(runtime.sessions.maybeProvideInfo(undefined).hooks)).toEqual(['session'])
await runtime.dispose()
})
it('records service-face calls; open() moves the selection and clear() empties it', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' })
runtime.sessions.open('s1' as SessionId)
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
runtime.sessions.clear()
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
expect(runtime.sessions.calls).toEqual([
{ method: 'open', args: ['s1'] },
{ method: 'clear', args: [] },
])
await runtime.dispose()
})
})
describe('stores', () => {
const createSuiteStore = () => defineStore({
init: () => ({ note: '' }),
persist: 'trt.store',
actions: { setNote: (d, note: string) => { d.note = note } },
})
it('resolves per-session instances via the host face: shared identity, isolation, action-driven re-render', async () => {
const runtime = await runtimeWithFrame()
const handle = createSuiteStore()
runtime.slots.register(
{ name: 'trt.chat', store: handle },
(props: SessionStandardProps & { useStore: <S>(sel: (s: { note: string }) => S) => S }) =>
<span>note:{props.useStore(s => s.note)}</span>)
const view = runtime.renderRoot()
await runtime.sessions.add({ id: 's1' })
expect(() => runtime.storeOf('trt.panel')).toThrow(/no registration/)
const store = runtime.storeOf('trt.chat', 's1')
await runtime.flush()
;(store.actions['setNote'] as (note: string) => void)('hello')
await runtime.flush()
expect(view.container.textContent).toContain('note:hello')
expect(runtime.storeOf('trt.chat', 's1')).toBe(store) // cached per scope key
await runtime.sessions.add({ id: 's2' })
const other = runtime.storeOf('trt.chat', 's2')
expect(other).not.toBe(store)
expect(other.getSnapshot()).toEqual({ note: '' })
await runtime.dispose()
})
it('storeOf guards: before renderRoot, and for storeless entries', async () => {
const runtime = await runtimeWithFrame()
runtime.slots.register({ name: 'trt.panel' }, () => null)
expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/)
runtime.renderRoot()
expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/)
await runtime.dispose()
})
it('remove() prunes the session store scope: persisted state clears, a re-added session starts fresh', async () => {
const runtime = await runtimeWithFrame()
const handle = createSuiteStore()
runtime.slots.register({ name: 'trt.chat', store: handle }, () => null)
runtime.renderRoot()
await runtime.sessions.add({ id: 's1' })
const doomed = runtime.storeOf('trt.chat', 's1')
;(doomed.actions['setNote'] as (note: string) => void)('buried')
expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
await runtime.sessions.remove('s1')
expect(localStorage.getItem('trt.store.s1')).toBeNull()
expect(runtime.sessions.list.getSnapshot().ids).toEqual([])
expect(runtime.sessions.provideInfo('s1')).toBeUndefined()
await runtime.sessions.add({ id: 's1' })
const reborn = runtime.storeOf('trt.chat', 's1')
expect(reborn).not.toBe(doomed)
expect(reborn.getSnapshot()).toEqual({ note: '' })
await runtime.dispose()
})
it('remove() also disposes a minted scope fiber; removing a non-current session keeps the selection', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' }, { current: false })
const scope = runtime.sessions.scope('s1')!
await runtime.sessions.remove('s2')
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
await runtime.sessions.remove('s1')
expect(scope.fiber.uid).toBeNull() // disposed fiber loses its uid
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
await runtime.dispose()
})
})
describe('workspaces', () => {
it('feeds useWorkspaces and records/stubs intent actions', async () => {
const runtime = await runtimeWithFrame()
runtime.slots.register(
{ name: 'trt.panel' },
(props: { useWorkspaces: <S>(sel: (s: { phase: string }) => S) => S }) =>
<span>ws:{props.useWorkspaces(s => s.phase)}</span>)
const view = runtime.renderRoot()
expect(view.container.textContent).toContain('ws:ready')
await runtime.workspaces.update((draft) => { draft.phase = 'pending' })
expect(view.container.textContent).toContain('ws:pending')
runtime.workspaces.startSession('w1' as WorkspaceId)
await expect(runtime.workspaces.connectWorkspace('w2' as WorkspaceId)).resolves.toBe('session-of-w2')
expect(runtime.workspaces.calls).toEqual([
{ method: 'startSession', args: ['w1'] },
{ method: 'connectWorkspace', args: ['w2'] },
])
const stub = vi.fn(() => Promise.resolve('other' as never))
runtime.workspaces.stub('connectWorkspace', stub)
await expect(runtime.workspaces.connectWorkspace('w3' as WorkspaceId)).resolves.toBe('other')
expect(stub).toHaveBeenCalledOnce()
await runtime.dispose()
})
it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => {
const runtime = await runtimeWithFrame()
// Defaults: an empty home level and parent/name joining.
await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] })
await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' })
await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh')
// The recorded signal seat mirrors the production face (undefined here;
// cancellation tests pass and observe a real one).
expect(runtime.workspaces.calls).toEqual([
{ method: 'listDirectory', args: [undefined, undefined] },
{ method: 'listDirectory', args: ['/home/test', undefined] },
{ method: 'createDirectory', args: ['/home/test', 'fresh'] },
])
// Stubs replace the defaults like every sibling method.
const listing = { path: '/x', home: '/x', crumbs: [], entries: [] }
const listStub = vi.fn(() => Promise.resolve(listing as never))
runtime.workspaces.stub('listDirectory', listStub)
runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never)))
const scan = new AbortController()
await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing)
// The stub receives the signal too, like the production face gives the wire.
expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal)
await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made')
await runtime.dispose()
})
})
describe('feature mount and disposal', () => {
it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => {
const runtime = await runtimeWithFrame()
runtime.provide('layout', { openDetails: vi.fn() })
const feature = await runtime.mount({
inject: ['slots', 'layout'],
apply: (ctx: typeof runtime.ctx) => {
ctx.provide('feature-service', { ok: true })
ctx.slots.register({
name: 'trt.rows',
id: 'row-1',
children: { 'trt.rows.hole': { kind: 'single', scope: 'root' } },
} as never, ((props: { renderSlot: (key: string, owner: object) => unknown }) =>
<div data-testid="row">{props.renderSlot('trt.rows.hole', {}) as React.ReactNode}</div>) as never)
},
})
const view = runtime.renderRoot()
expect(view.getByTestId('row')).toBeTruthy()
expect(runtime.ctx.get('feature-service')).toEqual({ ok: true })
expect(runtime.slots.entries('trt.rows')).toHaveLength(1)
await feature.dispose()
await feature.dispose() // idempotent
expect(runtime.slots.entries('trt.rows')).toHaveLength(0)
expect(runtime.slots.spec('trt.rows.hole' as never)).toBeUndefined()
expect(runtime.ctx.get('feature-service')).toBeUndefined()
expect(view.queryByTestId('row')).toBeNull()
await runtime.dispose()
})
it('mount fails loud on missing services instead of suspending forever', async () => {
const runtime = await runtimeWithFrame()
await expect(runtime.mount({ inject: ['slots', 'absent-service'], apply: () => {} }))
.rejects.toThrow(/missing service\(s\) absent-service/)
await runtime.dispose()
})
it('runtime dispose is idempotent, unmounts views, disposes mounted features, and clears persisted state', async () => {
const runtime = await runtimeWithFrame()
const feature = await runtime.mount({
inject: ['slots'],
apply: (ctx: typeof runtime.ctx) => { ctx.slots.register({ name: 'trt.panel' }, () => <b>p</b>) },
})
const view = runtime.renderRoot()
expect(view.container.textContent).toContain('p')
localStorage.setItem('trt.leftover', 'x')
await runtime.dispose()
expect(view.container.innerHTML).toBe('')
expect(feature.fiber.uid).toBeNull()
expect(localStorage.getItem('trt.leftover')).toBeNull()
await runtime.dispose() // idempotent
await expect(runtime.dispose()).resolves.toBeUndefined()
})
})
describe('single-slot mounting (declare + renderSlot)', () => {
it('renders one slot inside its data-slot wrapper and updates owner props in place', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
runtime.slots.register(
{ name: 'trt.panel' },
({ label }: { label?: string }) => <b data-testid="panel">{label ?? 'none'}</b>)
const slot = runtime.renderSlot('trt.panel', { label: 'first' })
expect(slot.container.getAttribute('data-slot')).toBe('trt.panel')
expect(slot.view.getByTestId('panel').textContent).toBe('first')
const panel = slot.view.getByTestId('panel')
slot.update({ label: 'second' })
expect(slot.view.getByTestId('panel').textContent).toBe('second')
// In-place re-render: the element identity survived the owner flip.
expect(slot.view.getByTestId('panel')).toBe(panel)
await runtime.dispose()
})
it('views sibling slots of one tree separately and rejects undeclared keys', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({
'trt.panel': { kind: 'single', scope: 'root' },
'trt.rows': { kind: 'list', scope: 'root' },
})
runtime.slots.register({ name: 'trt.panel' }, () => <b>panel</b>)
runtime.slots.register({ name: 'trt.rows', id: 'r1' }, () => <i>row</i>)
const panel = runtime.renderSlot('trt.panel', {})
const rows = runtime.renderSlot('trt.rows', {})
expect(panel.container.textContent).toBe('panel')
expect(rows.container.textContent).toBe('row')
expect(() => runtime.renderSlot('trt.chat', {})).toThrow(/without declare\(\)/)
await runtime.dispose()
})
it('folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
runtime.slots.register({ name: 'trt.panel' }, () => (
<div className="_frame_a1b2c3 plain">
<span className="_label_ff00aa">styled</span>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M0 0L16 16" fill="currentColor" />
</svg>
</div>
))
const slot = runtime.renderSlot('trt.panel', {})
expect(slot.container).toMatchSnapshot()
// The serializer works on a clone: the live DOM keeps hashes and paths.
expect(slot.container.querySelector('div')!.className).toBe('_frame_a1b2c3 plain')
expect(slot.container.querySelector('svg path')).not.toBeNull()
await runtime.dispose()
})
})
describe('fixture session face', () => {
it('fail-loud stubs name the missing verb; supplied overrides run instead', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.sessions.add({ id: 's1' })
const bare = runtime.sessions.behavior('s1')
expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
expect(() => bare.command()).toThrow(/command is not stubbed/)
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
await runtime.dispose()
})
it('projections faces are identity-stable per key, read absent, and notify on set', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.sessions.add({ id: 's1' })
const session = runtime.sessions.behavior('s1')
const face = session.projections.faceOf('todos')
expect(session.projections.faceOf('todos')).toBe(face)
expect(face.getSnapshot()).toBeUndefined()
const seen: unknown[] = []
const off = face.subscribe(() => { seen.push(face.getSnapshot()) })
session.projections.set('todos', [1, 2])
expect(seen).toEqual([[1, 2]])
off()
session.projections.set('todos', [3])
expect(seen).toEqual([[1, 2]]) // unsubscribed
// A never-subscribed key sets without listeners (the empty-notify arm).
session.projections.set('untouched', 1)
// The provide bundle hands the same store to the render side.
const info = runtime.sessions.provideInfo('s1')!
expect(info.projections?.faceOf('todos').getSnapshot()).toEqual([3])
// A roster change rebuilds the ALREADY-materialized bundle eagerly
// (production channel semantics: mounted entries must see the provider)
// and skips never-materialized records (they pick the roster up lazily).
await runtime.sessions.add({ id: 's-lazy' }, { current: false })
const offProbe = runtime.sessions.provide({
hooks: ['probe2'],
resolve: () => ({ hooks: { probe2: { getSnapshot: () => 1, subscribe: () => () => {} } } }),
})
const rebuilt = runtime.sessions.provideInfo('s1')!
expect(rebuilt).not.toBe(info)
expect(rebuilt.hooks['probe2']).toBeDefined()
offProbe()
await runtime.dispose()
})
})
describe('workspaces action face', () => {
it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
const runtime = await SlotTestRuntime.create()
const ws = runtime.workspaces
const created = await ws.create({ name: 'alpha' })
expect(created.title).toBe('alpha')
const registered = await ws.create({ path: '/tmp/beta' })
expect(registered.path).toBe('/tmp/beta')
await expect(ws.pickDirectory()).resolves.toBeNull()
const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed')
expect(renamed.title).toBe('Renamed')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/proj/file.ts')
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
expect(moved.sessionIds).toEqual(['s1'])
expect(ws.calls.map(c => c.method)).toEqual(
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore'])
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
ws.stub('delete', () => Promise.resolve())
ws.stub('openPath', () => Promise.resolve())
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
expect((await ws.create({ name: 'y' })).title).toBe('X')
await expect(ws.pickDirectory()).resolves.toBe('/picked')
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/other')
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
await runtime.dispose()
})
})
describe('single-slot mounting edge arms', () => {
it('renderSlot fails loud after dispose and after an external unmount', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
runtime.slots.register({ name: 'trt.panel' }, () => <b>p</b>)
runtime.renderSlot('trt.panel', {})
// RTL cleanup empties the mounted tree behind the runtime's back: the
// wrapper lookup names the state instead of returning a dead container.
cleanup()
expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/rendered no wrapper/)
await runtime.dispose()
// After dispose the root registration is gone: the production boot-order
// check fires before any wrapper lookup.
expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/'root' has no registration/)
})
it('serializes childless svg untouched next to scoped classes', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
runtime.slots.register({ name: 'trt.panel' }, () => (
<div className="_frame_a1b2c3">
<svg viewBox="0 0 1 1" aria-hidden="true" />
</div>
))
const slot = runtime.renderSlot('trt.panel', {})
expect(slot.container).toMatchSnapshot()
await runtime.dispose()
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-slots"
},
{
"path": "../web-react"
},
{
"path": "../runtime"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,6 +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: 17bc4edd7d002d6bba4470c9418a9179b2cb131b
README.zh.md: 1291556409b993aa893e102386f75c45bb195adf
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da
README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md).
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.

View File

@@ -4,7 +4,7 @@
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpacematchEnter 裁决钩子的 `/` 命令 source、三型派发executepopupSelectleadingInput以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)``decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-louddecoration装饰则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claimspace / 带参 enter与生命周期记账被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key每个会话恒为 agent-backed因此 `command.list({sessionId})` 是唯一的寻址形状source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。

View File

@@ -15,6 +15,10 @@
min-width: 220px;
max-height: 320px;
overflow-y: auto;
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);

View File

@@ -43,6 +43,24 @@ export interface CommandContribution {
readonly ui: CommandUiSpec
}
/**
* A UI decoration hung on one HOST command: what its BARE invocation does on
* this client. Not a second command — the host command keeps its catalog
* row, its argument claim (space / argued enter), and its lifecycle logging;
* the decoration replaces only the bare menu-pick/enter with a popup whose
* onSelect typically submits a completed line back through command.execute.
* A decoration never manufactures a row: a name with no host catalog entry
* in the session's directory simply never reaches the decoration.
*/
export interface CommandDecoration {
/** The HOST command name this decorates (without the leading slash). */
readonly name: string
/** Capability filter, called with a fresh projection per bare invocation. */
available(session: ClientSessionContext): boolean
/** The bare-invocation UI (this phase: popupSelect only). */
readonly ui: CommandUiSpec
}
/** The `ctx.command` service face visible to business packages. */
export interface CommandServiceContract {
/**
@@ -50,6 +68,11 @@ export interface CommandServiceContract {
* names throw at registration.
*/
register(contribution: CommandContribution): () => void
/**
* Hang a bare-invocation decoration on one host command; effect disposer.
* Duplicate names throw at registration.
*/
decorate(decoration: CommandDecoration): () => void
/** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
popupFor(actx: ClientContext): unknown
}

View File

@@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
export type { PopupSelectInjected } from './PopupSelectView.tsx'
export type {
CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption,
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
} from './contract.ts'
declare module 'cordis' {

View File

@@ -9,12 +9,12 @@
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
SlashServiceContract, SubmitOutcome,
SubmitOutcome,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandServiceContract } from './contract.ts'
import type { CommandContribution, CommandDecoration, CommandServiceContract } from './contract.ts'
import type { CommandDescriptor } from './directory.ts'
import { CommandDirectory } from './directory.ts'
import { PopupSelectController } from './popup.ts'
@@ -23,6 +23,7 @@ import type { TokenSegment } from './popup.ts'
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
interface LiveState {
readonly contributions: Map<string, CommandContribution>
readonly decorations: Map<string, CommandDecoration>
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
}
@@ -31,7 +32,7 @@ export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection']
private readonly directory: CommandDirectory
private readonly live: LiveState = { contributions: new Map(), popups: new Map() }
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
/**
* @param ctx - owning root context (plugin fiber; the service registers
@@ -46,7 +47,7 @@ export class CommandService extends Service implements CommandServiceContract {
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
return result.value.commands
})
const slash = ctx.get('slash') as SlashServiceContract | undefined
const slash = ctx.get('slash')
if (slash === undefined) throw new Error('ui-command: slash service unavailable')
ctx.effect(() => slash.registerSource({
trigger: '/',
@@ -79,6 +80,24 @@ export class CommandService extends Service implements CommandServiceContract {
return () => { void dispose() }
}
/**
* Hang a bare-invocation decoration on one host command; effect disposer
* (rides the caller's fiber). Duplicate names throw.
* @param decoration - host command name + availability + popup spec.
* @returns the disposer removing the registration.
*/
decorate(decoration: CommandDecoration): () => void {
const dispose = this.ctx.effect(() => {
const { decorations } = this.live
if (decorations.has(decoration.name)) {
throw new Error(`ui-command: duplicate decoration for /${decoration.name}`)
}
decorations.set(decoration.name, decoration)
return () => { decorations.delete(decoration.name) }
}, 'command.decorate()')
return () => { void dispose() }
}
/**
* Resolve the per-session popup controller (lazy; dies with the session
* scope). The controller's consume callback dispatches the scoped
@@ -148,16 +167,24 @@ export class CommandService extends Service implements CommandServiceContract {
.filter(c => req.position === 'leading' || c.hint === undefined)
}
/** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */
/** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
private dispatch(pick: SlashPick): PickOutcome {
const name = pick.candidate.name
const contribution = this.live.contributions.get(name)
if (contribution !== undefined && contribution.available(pick.session)) {
this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span })
this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
return 'handled'
}
const desc = this.directory.resolve(pick.session.sessionId, name)
if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
// A decoration replaces the HOST row's bare invocation with its popup;
// it decorates only a resolvable host command (checked above), never
// manufactures one, and never touches the argument claim below.
const decoration = this.live.decorations.get(name)
if (decoration !== undefined && decoration.available(pick.session)) {
this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
return 'handled'
}
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
// Menu-pick execute consumes the trigger span before the detached run
// (scoped event; the input owns the CAS guard).
@@ -193,12 +220,21 @@ export class CommandService extends Service implements CommandServiceContract {
const contribution = this.live.contributions.get(name)
if (contribution !== undefined && contribution.available(session)) {
if (!bare) return undefined
this.openPopup(contribution, session, { via: 'enter', token })
this.openPopup(name, contribution.ui, session, { via: 'enter', token })
return 'handled'
}
await this.directory.ensureReady(session.sessionId, signal)
const desc = this.directory.resolve(session.sessionId, name)
if (desc === undefined) return undefined
// Bare enter on a decorated host command opens its popup; an argued line
// never consults the decoration (the claim/detached paths below own it).
if (bare) {
const decoration = this.live.decorations.get(name)
if (decoration !== undefined && decoration.available(session)) {
this.openPopup(name, decoration.ui, session, { via: 'enter', token })
return 'handled'
}
}
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
if (!bare) return undefined
this.consumeVia(session.sessionId, { via: 'enter', token })
@@ -206,15 +242,16 @@ export class CommandService extends Service implements CommandServiceContract {
return 'handled'
}
/** Open the session's popup for one contribution (menu pick / bare enter). */
/** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
private openPopup(
contribution: CommandContribution,
name: string,
ui: CommandContribution['ui'],
session: ClientSessionContext,
segment: TokenSegment,
): void {
const actx = this.scopeFor(session.sessionId)
if (actx === undefined) return
this.popupFor(actx).open(contribution.name, contribution.ui, session, segment)
this.popupFor(actx).open(name, ui, session, segment)
}
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
@@ -227,7 +264,15 @@ export class CommandService extends Service implements CommandServiceContract {
}
}
/** The command.execute transaction, addressed to the session's agent. */
/**
* The command.execute transaction, addressed to the session's agent — pure
* admission semantics. An unmatched line reports an error outcome (the
* composer's immediate admission feedback); an admitted command reports
* plain success regardless of its handler outcome, because the host
* executor durably logged the lifecycle (`command/run`/`command/done`) and
* the outcome renders as a persistent flow node — the composer never
* echoes it. Transport failures throw.
*/
private async execute(
session: ClientSessionContext,
line: string,
@@ -236,25 +281,25 @@ export class CommandService extends Service implements CommandServiceContract {
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
const detached = result.value.result
return detached === undefined
? { kind: 'success' }
: { kind: detached.kind, ...(detached.text !== undefined ? { text: detached.text } : {}) }
return { kind: 'success' }
}
/**
* Fire-and-forget execute for the internal ('handled') paths. The detached
* result surfaces as a notice routed to the triggering session's composer,
* so a late result lands on its own session after a switch.
* Fire-and-forget execute for the internal ('handled') paths. Outcomes are
* NOT surfaced here: the host executor durably logs the command lifecycle
* (`command/run`/`command/done`), and the mux-broadcast events render as a
* persistent flow node on every tab. Only a transport/admission failure —
* which never entered a handler and therefore never logged — falls back to
* the composer notice as immediate feedback.
*/
private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
void this.execute(session, line).then(
(outcome) => {
if (outcome.kind === 'error') this.noticeFor(session.sessionId, desc.name, 'error', outcome.text ?? `/${desc.name} failed`)
else if (outcome.text !== undefined) this.noticeFor(session.sessionId, desc.name, 'info', outcome.text)
// matched:false maps to an error outcome with no logged lifecycle.
if (outcome.kind === 'error') this.noticeFor(session.sessionId, 'error', outcome.text ?? `/${desc.name} failed`)
},
(error: unknown) => {
this.noticeFor(session.sessionId, desc.name, 'error', error instanceof Error ? error.message : String(error))
this.noticeFor(session.sessionId, 'error', error instanceof Error ? error.message : String(error))
},
)
}
@@ -270,8 +315,8 @@ export class CommandService extends Service implements CommandServiceContract {
})
}
/** Route a detached result to the session's composer notice channel (scope gone = attempt died with it). */
private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void {
/** Route an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */
private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void {
const actx = this.scopeFor(id)
if (actx === undefined) return
const conversation = actx.get('conversation')
@@ -284,7 +329,7 @@ export class CommandService extends Service implements CommandServiceContract {
return this.sessions().scope(id)
}
private sessions(): SessionsService {
private sessions(): ISessions {
const sessions = this.ctx.get('sessions')
if (sessions === undefined) throw new Error('ui-command: sessions service unavailable')
return sessions

View File

@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
import type { CommandDescriptor } from '../src/client/directory.ts'
import { CommandService } from '../src/client/service.ts'
@@ -31,7 +31,7 @@ const S2_CMDS: CommandDescriptor[] = [
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
]
type ExecuteValue = { matched: boolean; result?: { kind: 'success' | 'error'; text?: string } }
type ExecuteValue = { matched: boolean }
interface BenchOptions {
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
@@ -197,6 +197,68 @@ describe('candidates', () => {
command.register(themeContribution({ name: 'plan' }))
await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
})
})
describe('decorations (bare-invocation UI on host commands)', () => {
const goalDecoration = (over: Partial<CommandDecoration> = {}): CommandDecoration => ({
name: 'goal',
available: () => true,
ui: themeUi(),
...over,
})
it('adds no catalog row: the host row stands alone', async () => {
const { command, source } = await bench()
command.decorate(goalDecoration())
const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
expect(names).toEqual(['plan', 'goal'])
})
it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {
const { command, source, mint, warm } = await bench()
command.decorate(goalDecoration())
const scope = mint('s1')
await warm(proj('s1'))
expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal)
if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
expect(argued.claim.token).toBe('/goal ')
})
it('space never consults the decoration (host claim)', async () => {
const { command, source, warm } = await bench()
command.decorate(goalDecoration())
await warm(proj('s1'))
const outcome = source.matchSpace!(proj('s1'), '/goal')
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim')
expect(outcome.claim.token).toBe('/goal ')
})
it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => {
const { command, source, mint, warm } = await bench()
command.decorate(goalDecoration({ name: 'phantom' }))
const scope = mint('s1')
await warm(proj('s1'))
expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined()
expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
})
it('an unavailable decoration falls through to the host bare path (detached execute)', async () => {
const { command, source, warm, executeCalls } = await bench()
command.decorate(goalDecoration({ name: 'plan', available: () => false }))
await warm(proj('s1'))
expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled')
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
})
it('duplicate decoration names fail loud', async () => {
const { command } = await bench()
command.decorate(goalDecoration())
expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal')
})
})
describe('dispatch (menu column)', () => {
@@ -361,16 +423,18 @@ describe('matchEnter (enter column)', () => {
})
describe('execute payload', () => {
it('claim.submit addresses the session and maps the detached result', async () => {
it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
const { source, warm, executeCalls } = await bench({
execute: () => Promise.resolve({ matched: true, result: { kind: 'success', text: 'goal set' } }),
execute: () => Promise.resolve({ matched: true }),
})
await warm(proj('s1'))
const outcome = source.matchSpace!(proj('s1'), '/goal')
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
const settled = await outcome.claim.submit('ship it', new Context())
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
expect(settled).toEqual({ kind: 'success', text: 'goal set' })
// Pure admission: no outcome text ever rides the submit result — the
// durable command lifecycle events render the outcome in the flow.
expect(settled).toEqual({ kind: 'success' })
})
it('maps matched:false to an error outcome and a matched bare result to success', async () => {
@@ -389,33 +453,29 @@ describe('execute payload', () => {
})
})
describe('detached result notices', () => {
describe('detached admission notices', () => {
const flush = () => new Promise(resolve => setTimeout(resolve, 0))
it('success text → info; error result → error; rejection → error, all on the triggering session', async () => {
let mode: 'info' | 'error' | 'reject' = 'info'
it('admitted outcomes stay silent; admission miss and transport rejection notice as errors', async () => {
let mode: 'admitted' | 'miss' | 'reject' = 'admitted'
const { source, mint, warm, notices } = await bench({
execute: () => {
if (mode === 'reject') return Promise.reject(new Error('network down'))
return Promise.resolve({
matched: true,
result: mode === 'info'
? { kind: 'success' as const, text: 'compacted 12 messages' }
: { kind: 'error' as const, text: 'plan mode refused' },
})
return Promise.resolve({ matched: mode === 'admitted' })
},
})
mint('s1')
await warm(proj('s1'))
// Admitted: the durable lifecycle events own the outcome — no notice.
menuPick(source, 'plan', proj('s1'))
await flush()
expect(notices).toEqual([{ scope: sid('s1'), level: 'info', text: 'compacted 12 messages' }])
expect(notices).toEqual([])
notices.length = 0
mode = 'error'
// Admission miss (matched:false): immediate composer feedback stays.
mode = 'miss'
await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
await flush()
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'plan mode refused' }])
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
notices.length = 0
mode = 'reject'
@@ -424,9 +484,9 @@ describe('detached result notices', () => {
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }])
})
it('success without text stays silent; a torn-down scope drops the notice', async () => {
it('a torn-down scope drops the failure notice', async () => {
const { source, warm, notices } = await bench({
execute: () => Promise.resolve({ matched: true, result: { kind: 'success' as const, text: 'orphan' } }),
execute: () => Promise.reject(new Error('orphan failure')),
})
await warm(proj('ghost')) // never minted: scopeFor misses
menuPick(source, 'plan', proj('ghost'))

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: 56a445ccfa86e0b11cf5aefc37819a30746f0739
README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070
README.md: e2148cfca658196540e3800912dccd0568ae8d0e
README.zh.md: 45f05ce2e03e015701e85f2853a4a656511058a9

View File

@@ -8,15 +8,17 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
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`/`openDetails`) 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).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
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>` parsed from its args, 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). `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). 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.
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>` parsed from its args, 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). `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). 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.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
@@ -32,7 +34,7 @@ None; this package neither assembles nor sends a provider request.
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.

View File

@@ -8,15 +8,17 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openDetails``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 会拒绝没有任何渲染方的声明)。
工具行同样是 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 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` `order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是常驻的计划条它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chip选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`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 提供其余状态和回调。
输入栏为 `'conversation.input.plan'` `'conversation.input.model'` 声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送/停止按钮之前。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)`'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。`plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
@@ -32,7 +34,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **统计行没有耗时区段**assistant `usage` 只携带 token 计数;耗时需要主机数据源。
- **详情面板是最小形态**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
- **assistant footer 扩展IconActions 行、逐消息分页是预留 slot**:设计中已有图稿,尚未实现。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批卡片只是只读占位符**问题请求通过编辑器链回答ui-questionWeb 侧审批回答属于 P-II 审批项目
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。

View File

@@ -49,11 +49,17 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"

View File

@@ -1,18 +1,22 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ViewTab } from './contract/views.ts'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import type { IConversation } from './service.ts'
import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
@@ -23,8 +27,8 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
const conversation = scoped.get('conversation')
@@ -32,6 +36,11 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
return conversation
}
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
}
/** Mounts the conversation plugin.
* @param ctx - Client root context.
*/
@@ -128,8 +137,9 @@ export function apply(ctx: Context): void {
// verbs ride this inject (package-internal — hub and bar are one plugin).
slots.register({
name: 'conversation.composer.bar',
// The two named control seats in the bar's tool row (plan left, model
// right); empty until their owning plugins register (B ruling).
// The two named control seats in the bar's tool row (plan beside the
// access control, model right); empty until their owning plugins
// register (B ruling).
children: {
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
@@ -143,11 +153,27 @@ export function apply(ctx: Context): void {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
command: async (line) => {
const session = sessions.binding(sessionId)?.session
if (session === undefined) return false
const result = await session.command(line)
return result.ok && result.value.matched
},
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},
}, InputBar)
// The approval takeover: a selector-routed entry of the chain this package
// just declared (the ui-question registration pattern; the entry lives here
// because approval answering is core conversation UX, not an optional tool).
// Zero business face — data and verbs both ride the matched carrier.
// priority 1: question takeovers (default 0) win when both kinds are
// pending — a question is a conversation the model is waiting on, while an
// approval only blocks one tool call; answering the question first cannot
// strand the approval (it re-elects the moment the question resolves).
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
// only component authorized to render per-tool rows. Shares the chat
@@ -158,7 +184,10 @@ export function apply(ctx: Context): void {
id: 'chat',
order: 0,
label: 'Chat',
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
children: {
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
const scoped = scopedConversation(sessions, sessionId)
@@ -167,6 +196,13 @@ export function apply(ctx: Context): void {
actions.select(target)
layout.openDetails()
},
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
// Host/OS open failures stay silent in the chat row; the native
// app surfaces its own error dialog when the path is unusable.
})
},
loadOlder: () => { void scoped.loadOlder() },
}
},

View File

@@ -1,14 +1,22 @@
/* Assistant flow body: full-width narration (figma 16/28), block gap 16. */
/* Assistant flow body: full-width narration (figma 16/28), block gap 16.
IconActions sit below the body with an explicit 16px top margin (figma
43:32997) — separate from the body's internal gap so the footer spacing
stays fixed when the body is a single block. */
.root {
display: flex;
flex-direction: column;
gap: 16px;
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
.stopped {
align-self: flex-start;
@@ -19,3 +27,18 @@
font-size: 11px;
line-height: 18px;
}
/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
.actions {
margin-top: 16px;
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
margin-left: -6px;
}
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
@media (hover: hover) {
.root:hover .actions,
.root:focus-within .actions {
opacity: 1;
}
}

View File

@@ -4,10 +4,14 @@
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -16,6 +20,8 @@ export interface AssistantMarkdownProps {
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
interrupted?: boolean | undefined
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
time?: number | undefined
}
function firstLine(text: string): string {
@@ -23,6 +29,15 @@ function firstLine(text: string): string {
return nl === -1 ? text : text.slice(0, nl)
}
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
function copyText(blocks: readonly AssistantBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
if (block.kind === 'text') parts.push(block.text)
}
return parts.join('')
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running }: { text: string; running: boolean }) {
return (
@@ -38,7 +53,9 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
)
}
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time,
}: AssistantMarkdownProps) {
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root
@@ -47,18 +64,30 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
// Footer only after the turn settles with a known event time; streaming omits it.
const showActions = !streaming && time !== undefined
return (
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
}
})}
{interrupted && <span className={css.stopped}></span>}
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
}
})}
{interrupted && <span className={css.stopped}></span>}
</div>
{showActions && (
<MessageIconActions
text={copyText(blocks)}
time={time}
clock="end"
className={css.actions}
/>
)}
</div>
)
})

View File

@@ -20,23 +20,22 @@ import {
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
CodeSubCall, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { SelectionTarget } from '../contract/views.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { PendingCard } from './PendingCard.tsx'
import { StatsLine } from './StatsLine.tsx'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
type OpenFile = (path: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
@@ -49,20 +48,18 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected, cwd }: {
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
renderSlot: RenderToolRow
node: CodeSubCall
onOpenDetails: OpenDetails
openFile: OpenFile
selected: boolean
cwd: string | undefined
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const seq = settled ? node.seq : node.time
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
}), [node, toolName, seq, cwd, onOpenDetails])
callId: node.callId, toolName, block: node, openFile, cwd,
}), [node, toolName, openFile, cwd])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -79,15 +76,13 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd,
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
}: {
renderSlot: RenderToolRow
callId: string
toolName: string
block: ToolResultNode | RunningToolCall
/** Surface seq for finalized results; the call's turn for running calls. */
seq: number
onOpenDetails: OpenDetails
openFile: OpenFile
selected: boolean
/** `run_code` sub-dispatches in dispatch order (reference-stable per
* parent; running entries settle in place); undefined for ordinary calls. */
@@ -98,9 +93,8 @@ const CallRow = memo(function CallRow({
cwd: string | undefined
}) {
const owner = useMemo(() => ({
callId, toolName, block, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, cwd, onOpenDetails])
callId, toolName, block, openFile, cwd,
}), [callId, toolName, block, openFile, cwd])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -114,7 +108,7 @@ const CallRow = memo(function CallRow({
key={node.callId}
renderSlot={renderSlot}
node={node}
onOpenDetails={onOpenDetails}
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd}
/>
@@ -126,10 +120,10 @@ const CallRow = memo(function CallRow({
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
openFile: OpenFile
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
selectedCallId: string | undefined
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
@@ -146,8 +140,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
seq={node.seq}
onOpenDetails={onOpenDetails}
openFile={openFile}
selected={node.callId === selectedCallId}
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
@@ -158,6 +151,24 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
)
})
/** One command lifecycle row: keyed dispatch on the command name with the
* generic card as the render-site fallback (zero registration required). A
* run-less cross-window node has no name and always lands on the fallback. */
const CommandRow = memo(function CommandRow({ renderSlot, node }: {
renderSlot: RenderToolRow
node: CommandNode
}) {
const owner = useMemo(() => ({ node }), [node])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback: <GenericCommandCard {...owner} />,
})}
</div>
)
})
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
* 2px cell, same blue) chasing left to right with a stepped trail — flat
* keyframe holds, no tweening, no rotation. Phase offsets come from
@@ -210,14 +221,13 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) {
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const pending = useSession(s => s.pending)
const openState = useSession(s => s.openState)
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession(s => s.hasMore)
@@ -311,7 +321,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
key={item.key}
renderSlot={renderSlot}
results={item.results}
onOpenDetails={openDetails}
openFile={openFile}
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
@@ -320,7 +330,18 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
return (
<AssistantMarkdown
key={item.key}
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
time={node.time}
/>
)
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
@@ -351,8 +372,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={openDetails}
openFile={openFile}
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
@@ -361,7 +381,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
))}
</div>
)}
{pending.map(item => <PendingCard key={item.key} item={item} />)}
{/* No pending placeholders: questions (ui-question) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}

View File

@@ -0,0 +1,38 @@
// GenericCommandCard: the default command row — a stripped-down
// GenericToolCard rendering the dispatched command line and the settlement
// text. Supplied by the chat view as the keyed commandview slot's render-site
// fallback (an unregistered command name lands here); registrants may compose
// it as a base, feeding the same owner payload through.
import { ToolRow } from './ToolRow.tsx'
import type { ToolRowState } from '../contract/tool-call-model.ts'
import type { CommandRowOwnerProps } from '../contract/slots.ts'
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
/** Node state → row state semantic (running while unsettled; outcome kind after). */
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState {
if (outcome === null) return 'running'
return outcome.kind === 'error' ? 'error' : 'ok'
}
export function GenericCommandCard({ node }: CommandRowOwnerProps) {
const text = node.outcome?.text
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
// Display line rebuilt from the structured payload (args carries its own
// separator whitespace verbatim); a cross-window node whose run page fell
// out of the window has neither.
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
return (
<ToolRow
variant="others"
icon={<IconApiOutline14 size={16} />}
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.
body={text !== undefined && text.includes('\n') ? text : null}
state={stateOf(node.outcome)}
/>
)
}

View File

@@ -6,12 +6,12 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx'
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
@@ -25,8 +25,9 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
others: <IconSparkle16 size={14} />,
}
export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) {
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block, cwd)
const singleFile = model.filePath !== undefined
return (
<ToolRow
variant={model.variant}
@@ -34,9 +35,11 @@ export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOw
icon={VARIANT_ICONS[model.variant]}
title={model.title}
summary={model.summary}
body={model.body}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
state={model.state}
onOpenDetails={openDetails}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
/>
)
}

View File

@@ -1,15 +0,0 @@
// Local sparkle icon for the Others tool-row variant (figma 43:31850 leading
// glyph is an SF Symbols "sparkles" text glyph — not extractable as vector
// data, so this is a hand-authored three-star approximation). Lives here
// rather than ui-primitives until the exact glyph is exported and adopted
// into the ic_ds_* family.
export function IconSparkle16({ size = 16, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.1 3.1Q6.6 7.8 11.3 8.3Q6.6 8.8 6.1 13.5Q5.6 8.8 0.9 8.3Q5.6 7.8 6.1 3.1Z" fill="currentColor" />
<path d="M11.9 1Q12.2 3.7 14.9 4Q12.2 4.3 11.9 7Q11.6 4.3 8.9 4Q11.6 3.7 11.9 1Z" fill="currentColor" />
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
</svg>
)
}

View File

@@ -0,0 +1,53 @@
/* Shared message IconActions row (user + assistant). Parent modules own
hover-reveal selectors and layout offsets via the composed className. */
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */
.timeStart {
padding-right: 12px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
.timeEnd {
padding-left: 12px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}

View File

@@ -0,0 +1,65 @@
// Shared IconActions chrome for user and assistant messages: copy / branch
// live (branch still a stub), date-aware clock, optional edit stub.
import { useCallback } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
export interface MessageIconActionsProps {
/** Plain text the copy action writes. */
text: string
/** Unix epoch ms for the clock label. */
time: number
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Parent layout / hover-reveal class composed onto the actions row. */
className?: string | undefined
}
/**
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
* @param props - Copy text, event time, clock side, optional edit, className.
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, className,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
const clockEl = (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, day)}
</span>
)
return (
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
{clock === 'start' ? clockEl : null}
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
{edit === true && (
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
)}
{clock === 'end' ? clockEl : null}
</div>
)
}

View File

@@ -20,46 +20,14 @@
color: var(--dsw-alias-label-primary);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
hover:none keeps actions visible (opacity:0 still hit-tests). */
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
.userRow:hover .actions,
.userRow:focus-within .actions {
opacity: 1;
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.badge {
display: inline-block;
margin-bottom: 4px;

View File

@@ -1,18 +1,16 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// copy / branch / edit IconActions), steering (badged bubble), context
// clock + copy / branch / edit IconActions), steering (badged bubble), context
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
import { memo, useCallback } from 'react'
import { memo } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
JsonBlock, MessageText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
@@ -30,42 +28,6 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
/* eslint-enable @typescript-eslint/no-deprecated */
el.remove()
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -98,32 +60,6 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
function UserActions({ text }: { text: string }) {
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
return (
<div className={css.actions}>
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
</div>
)
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
switch (node.kind) {
case 'user': {
@@ -134,7 +70,13 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
<UserActions text={text} />
<MessageIconActions
text={text}
time={node.time}
clock="start"
edit
className={css.actions}
/>
</div>
)
}

View File

@@ -1,31 +0,0 @@
/* Amber pending strip (approval waiting = warn semantic, figma state colors). */
.card {
margin: 6px 0;
padding: 8px 12px;
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 10px;
background: var(--dsw-alias-state-warn-tertiary);
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.mono {
font-family: var(--ds-font-family-code);
}
.reason {
margin-top: 4px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.hint {
margin-top: 6px;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -1,31 +0,0 @@
// PendingCard: approval/question placeholder card (visible, not answerable —
// the composer-takeover approval panel is a P-II item; wire pending semantics
// already exist so the flow must show them).
import { memo } from 'react'
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './PendingCard.module.css'
export interface PendingCardProps {
item: PendingInteraction
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
<div className={css.card}>
{item.kind === 'approval' ? (
<>
<div className={css.title}><span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
</>
) : (
<>
<div className={css.title}>{item.payload.questions.length} </div>
<JsonBlock label="问题内容" payload={item.payload.questions} />
</>
)}
<div className={css.hint}>web </div>
</div>
)
})

Some files were not shown because too many files have changed in this diff Show More