From 5da343126ccb71228492cf535a1700f28fac0ee0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:23:44 +0800 Subject: [PATCH 01/21] fix(windows): restore LSP and TUI coverage --- ...-22-cross-platform-test-fixtures.i18n.yaml | 4 +- ...2026-07-22-cross-platform-test-fixtures.md | 12 +- ...6-07-22-cross-platform-test-fixtures.zh.md | 12 +- packages/lsp/lsp-local/README.md | 3 +- packages/lsp/lsp-local/src/connection.ts | 157 ++++++++++++++---- packages/lsp/lsp-local/src/index.ts | 14 +- packages/lsp/lsp-local/src/instance.ts | 21 +-- .../lsp/lsp-local/tests/connection.spec.ts | 80 +++++++-- .../lsp/lsp-local/tests/fixture-server.ts | 17 +- packages/lsp/lsp-local/tests/instance.spec.ts | 47 ++++-- .../lsp/lsp-local/tests/lifecycle.spec.ts | 2 +- packages/ui/tui/tests/tui.spec.ts | 7 +- vitest.config.ts | 12 -- 13 files changed, 281 insertions(+), 107 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml index d1b133cb72..511b66e345 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml @@ -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 -2026-07-22-cross-platform-test-fixtures.md: 83af904db5d004366021d4ba6bead656ff813dae -2026-07-22-cross-platform-test-fixtures.zh.md: 3570c393f8d2fc3344aa43ff0eb8291500d07e1c +2026-07-22-cross-platform-test-fixtures.md: 56deaf6306e15c6cf17e83fcbaf36137e5c543f4 +2026-07-22-cross-platform-test-fixtures.zh.md: f61441e2dbe86fa5a580e666fcd08d23b0fadf0b diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md index 83af904db5..56deaf6306 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md @@ -6,7 +6,7 @@ English | [中文](2026-07-22-cross-platform-test-fixtures.zh.md) ## Problem -The unit and coverage suites run on Windows, macOS, and Linux, but a platform-neutral behavior can be hidden behind a platform-specific fixture. Literal POSIX paths become drive-relative paths on Windows, a hosted `file:` URI can be a valid UNC path there, and numeric file descriptor `0` is not the sole owner of Node's pipe-backed child stdin. POSIX-only filesystem states such as FIFOs, executable mode bits, and directory search bits have no direct Windows fixture. +The unit and coverage suites run on Windows, macOS, and Linux, but a platform-neutral behavior can be hidden behind a platform-specific fixture. Literal POSIX paths become drive-relative paths on Windows, a hosted `file:` URI can be a valid UNC path there, and child-pipe closure or event-loop scheduling does not settle at the same point on every host. POSIX-only filesystem states such as FIFOs, executable mode bits, and directory search bits have no direct Windows fixture. Treating fixture syntax as product behavior either reports false regressions or encourages production normalization that erases native path semantics. @@ -14,18 +14,20 @@ Treating fixture syntax as product behavior either reports false regressions or Tests of platform-neutral behavior construct absolute paths and `file:` URIs with the host's `node:path` and `node:url` APIs, then assert native absolute output or stable workspace-relative output as the contract requires. Invalid-URI fixtures use encodings rejected by `fileURLToPath()` on every supported platform. -Subprocess fixtures that require the parent write side to fail close both the CRT descriptor and the libuv handle owning child stdin. This pins the connection failure contract across POSIX descriptor-backed and Windows pipe-backed processes while keeping the child alive long enough to distinguish pipe failure from process exit. +Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles. -Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. +Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows, with a direct-child fallback when the tree is already gone. A read-only provider query retries once only when its pooled transport becomes dead after the liveness check; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. + +Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files. ## Alternatives considered **Normalize all paths and URIs to POSIX strings.** This would make assertions uniform but would change correct Windows behavior: external paths are native absolute paths, UNC file URIs are valid, and configured homes resolve through the host path rules. -**Run POSIX fixtures through a compatibility shell on Windows.** A compatibility environment would test different filesystem and process semantics from the native Node runtime exercised by the product. +**Manipulate child-pipe internals until a write fails.** CRT descriptors and libuv handles have different ownership across hosts and Node versions, so this would test undocumented fixture machinery instead of the connection's write-failure contract. **Skip whole files or packages on Windows.** Broad exclusions would hide supported behavior. Only the individual fixture whose state cannot exist on Windows is excluded; the surrounding contract remains covered. ## Consequences -Portable fixtures are slightly more verbose because expected paths derive from shared native constants. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Pipe-failure fixtures depend on Node's test-runtime handle shape, but that dependency stays inside the scripted child and proves the real parent-side stream behavior rather than mocking it. +Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns. diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md index 3570c393f8..f61441e2db 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -单元测试与覆盖率测试套件会在 Windows、macOS 和 Linux 上运行,但平台无关行为可能被平台特有的 fixture(测试前置数据)掩盖。字面 POSIX 路径在 Windows 上会变成相对于驱动器的路径;带主机名的 `file:` URI 在 Windows 上可能是有效的 UNC 路径;在 Node 中,编号为 `0` 的文件描述符也不是子进程管道型 stdin 的唯一持有者。FIFO、可执行模式位和目录搜索权限位等仅存在于 POSIX 的文件系统状态,在 Windows 上没有可直接构造的 fixture。 +单元测试与覆盖率测试套件会在 Windows、macOS 和 Linux 上运行,但平台无关行为可能被平台特有的 fixture(测试前置数据)掩盖。字面 POSIX 路径在 Windows 上会变成相对于驱动器的路径;带主机名的 `file:` URI 在 Windows 上可能是有效的 UNC 路径;子进程管道关闭或事件循环调度在不同宿主上的稳定时点也不一致。FIFO、可执行模式位和目录搜索权限位等仅存在于 POSIX 的文件系统状态,在 Windows 上没有可直接构造的 fixture。 把 fixture 语法当成产品行为,要么会误报回归,要么会促使生产代码引入抹去原生路径语义的归一化。 @@ -14,18 +14,20 @@ Status: implemented 测试平台无关行为时,使用宿主的 `node:path` 和 `node:url` API 构造绝对路径与 `file:` URI,再根据契约要求断言原生绝对输出或稳定的工作区相对输出。无效 URI fixture 使用一种在所有受支持平台上都会被 `fileURLToPath()` 拒绝的编码形式。 -需要使父进程写端失败的子进程 fixture 会同时关闭 CRT 文件描述符和持有子进程 stdin 的 libuv 句柄。这种方式在以 POSIX 文件描述符为后端的进程和以 Windows 管道为后端的进程上固定了连接失败契约,同时让子进程存活足够长的时间,以区分管道故障与进程退出。 +传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。 -对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。 +语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`;若进程树已经不存在,则回退到直接终止子进程。只读的提供方查询仅在池化传输于存活检查后失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 + +对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。 ## 曾考虑的替代方案 **将所有路径和 URI 归一化为 POSIX 字符串。**这会使断言保持一致,但也会改变正确的 Windows 行为:外部路径是原生绝对路径,UNC 文件 URI 有效,而且已配置的主目录会按照宿主路径规则解析。 -**在 Windows 上通过兼容性 shell 运行 POSIX fixture。**这种兼容环境测试的文件系统与进程语义不同于产品实际使用的原生 Node 运行时。 +**操纵子进程管道内部状态,直至写入失败。**CRT 描述符与 libuv 句柄在不同宿主和 Node 版本上的所有权不同,因此这种做法测试的是未文档化的 fixture 机制,而非连接的写入失败契约。 **在 Windows 上跳过整个测试文件或包。**过宽的排除会隐藏受支持的行为。只排除无法在 Windows 上构造相应状态的单项 fixture;相关契约仍保持覆盖。 ## 后果 -可移植 fixture 略显冗长,因为预期路径需要从共享的原生常量派生。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。管道故障 fixture 依赖 Node 测试运行时的句柄形态,但这种依赖仅存在于脚本化的子进程内;因此,这类 fixture 验证的是真实的父进程侧流行为,而不是对它进行 mock。 +可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;同步取得命令结果让 dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出。 diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 85cc7945dd..269fe6b666 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -7,9 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). ## What it does - Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. -- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. +- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the transport becomes dead between the pool's liveness check and a read-only query, the provider evicts it and retries that query once on a fresh process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. +- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`, with a direct-child fallback for teardown races. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index ae725b56f7..201cd87bdd 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -8,7 +8,7 @@ */ import type { ChildProcessByStdio } from 'node:child_process' -import { spawn } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import type { Readable, Writable } from 'node:stream' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { encodeMessage, MessageDecoder } from './framing.ts' @@ -36,6 +36,105 @@ interface Pending { reject: (error: Error) => void } +/** + * Write one JSON-RPC message to the child stdin. + * @param stdin - the spawned server stdin. + * @param message - the unencoded JSON-RPC message. + * @param done - callback that reports asynchronous stream settlement. + */ +export type ConnectionWriter = ( + stdin: Writable, + message: unknown, + done: (error?: Error | null) => void, +) => void + +/** Host operations used to signal a detached process tree. */ +export interface ProcessTreeOperations { + /** Signal a POSIX process group. */ + readonly signal: (target: number, signal: NodeJS.Signals) => void + /** Signal the direct child when group/tree signalling is unavailable. */ + readonly killChild: (signal: NodeJS.Signals) => void + /** Terminate a Windows process tree by root pid. */ + readonly taskkill: (pid: number) => void +} + +/** Narrow taskkill runner result used by the Windows process-tree adapter. */ +export interface TaskkillResult { + /** Process exit status, or null when spawning failed. */ + readonly status: number | null + /** Spawn failure, when the executable could not run. */ + readonly error?: Error +} + +/** Invoke a command synchronously for the Windows taskkill adapter. */ +export type TaskkillRunner = ( + command: string, + args: string[], + options: { stdio: 'ignore' }, +) => TaskkillResult + +/** Invoke the host process-signal primitive for a POSIX process group. */ +export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean + +const processSignalRunner: ProcessSignalRunner = process.kill.bind(process) + +const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => { + stdin.write(encodeMessage(message), done) +} + +/** + * Terminate one Windows process tree and wait for taskkill to finish. + * @param pid - root process id. + * @param run - command runner; tests inject results without requiring Windows. + */ +export function taskkillProcessTree( + pid: number, + run: TaskkillRunner = spawnSync, +): void { + const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`) +} + +/** + * Signal one POSIX process group through an injectable host primitive. + * @param target - negative process-group id. + * @param signal - requested signal. + * @param run - host signal runner; tests inject it without touching real processes. + */ +export function signalProcessGroup( + target: number, + signal: NodeJS.Signals, + run: ProcessSignalRunner = processSignalRunner, +): void { + run(target, signal) +} + +/** + * Signal a detached process tree with platform-correct semantics and a direct-child fallback. + * @param platform - host platform. + * @param pid - detached root process id. + * @param signal - requested termination signal. + * @param operations - host operations. + */ +export function signalProcessTree( + platform: NodeJS.Platform, + pid: number, + signal: NodeJS.Signals, + operations: ProcessTreeOperations, +): void { + try { + if (platform === 'win32') operations.taskkill(pid) + else operations.signal(-pid, signal) + } catch { + try { + operations.killChild(signal) + } catch { + // The direct child already exited; teardown remains idempotent. + } + } +} + /** A live JSON-RPC endpoint bound to one child process. */ export class LspConnection { private readonly child: ChildProcessByStdio @@ -50,14 +149,16 @@ export class LspConnection { /** * @param spec - how to launch the server and answer its config requests. * @param onServerRequest - answers a server→client request; rejects to send an error response. + * @param writer - message writer; tests inject callback failures without relying on OS pipe races. */ constructor( private readonly spec: ConnectionSpec, private readonly onServerRequest: (method: string, params: unknown) => Promise, + private readonly writer: ConnectionWriter = writeConnectionMessage, ) { this.decoder = new MessageDecoder(spec.maxMessageBytes) - // `detached` puts the server in its own process group so teardown can signal the WHOLE group - // (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver). + // `detached` gives teardown a process-tree root: POSIX signals its negative process-group id, + // while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it. this.child = spawn(spec.command, [...spec.args], { cwd: spec.cwd, env: spec.env, @@ -94,6 +195,11 @@ export class LspConnection { return this.stderr.toString('utf8') } + /** Whether the transport has failed even if the child close event has not arrived yet. */ + get failed(): boolean { + return this.closeReason !== undefined + } + /** * Send a request and await its result. * @param method - the JSON-RPC method. @@ -147,23 +253,23 @@ export class LspConnection { return this.nextId } - /** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */ + /** Request termination of the server's process tree. */ terminate(): void { - this.signalGroup('SIGTERM') + this.signalTree('SIGTERM') } - /** Send SIGKILL to the server's process group. */ + /** Force termination of the server's process tree. */ kill(): void { - this.signalGroup('SIGKILL') + this.signalTree('SIGKILL') } /** - * Wait until the owned process group has no members. + * Wait until the owned process tree has exited. * @param signal - optional bound for the wait. - * @returns `true` when the group exited, or `false` when the signal aborted first. + * @returns `true` when the tree exited, or `false` when the signal aborted first. */ - async waitForProcessGroupExit(signal?: AbortSignal): Promise { - while (this.processGroupAlive()) { + async waitForProcessTreeExit(signal?: AbortSignal): Promise { + while (this.processTreeAlive()) { if (signal?.aborted) return false await yieldToEventLoop() } @@ -171,26 +277,21 @@ export class LspConnection { } /** - * Signal the whole process group (negative pid) so helper processes are reached; fall back to the - * direct child if the group send fails. Never throws — teardown races process exit. + * Signal the whole process tree so helper processes are reached; fall back to the direct child if + * tree signaling fails. Never throws because teardown races process exit. */ - private signalGroup(sig: NodeJS.Signals): void { + private signalTree(sig: NodeJS.Signals): void { const pid = this.child.pid if (pid === undefined) return - try { - process.kill(-pid, sig) - } catch { - // The group is gone (already exited) or could not be signalled; try the direct child. - try { - this.child.kill(sig) - } catch { - // Already dead; nothing to signal. - } - } + signalProcessTree(process.platform, pid, sig, { + signal: signalProcessGroup, + killChild: this.child.kill.bind(this.child), + taskkill: taskkillProcessTree, + }) } - /** Whether the detached process group still has at least one member. */ - private processGroupAlive(): boolean { + /** Whether the detached tree's root or POSIX process group is still alive. */ + private processTreeAlive(): boolean { const pid = this.child.pid /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */ if (pid === undefined) return false @@ -218,7 +319,7 @@ export class LspConnection { // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and // SIGKILL the whole group so helper processes don't outlive the leader. this.fail(asError(error)) - this.signalGroup('SIGKILL') + this.signalTree('SIGKILL') return } for (const message of messages) this.dispatch(message) @@ -293,7 +394,7 @@ export class LspConnection { reject(error) } try { - this.child.stdin.write(encodeMessage(message), done) + this.writer(this.child.stdin, message, done) /* v8 ignore start -- Node stream write failures are callback-delivered; this guards a nonconforming Writable implementation throwing synchronously. */ } catch (error) { diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 095d761624..340d5a4df0 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -2,9 +2,9 @@ * Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table * of server commands and registers one isolated provider for each entry. Every provider lazily * single-flights one server process per canonical workspace realpath, serves transient-open queries - * through it, and evicts a crashed process so a later query can replace it. Providers read sources - * through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no - * sandbox confinement. + * through it, and replaces a transport that dies between a pool liveness check and the next + * read-only query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`) + * and trust their configured servers — no sandbox confinement. * * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal * unregisters from `ctx.lsp` and tears down every live server. @@ -227,6 +227,14 @@ class LocalLspProvider implements LspProvider { } try { return await instance.query(request, source, signal) + } catch (error) { + // A child can die after the pre-query liveness check but before or during the next write. + // Queries are read-only, so replace a newly failed transport once and retry transparently. + if (!instance.dead) throw error + this.evictIfCurrent(workspace, instance) + this.assertActive(signal) + instance = this.instanceFor(workspace) + return await instance.query(request, source, signal) } finally { // Drop a crashed slot only when it still owns this instance; a replacement must survive. if (instance.dead) this.evictIfCurrent(workspace, instance) diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 74381c1483..02bdd160d4 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -17,7 +17,7 @@ import type { import { deadline } from '@deepseek-ai/dsh-timeout' import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' -import type { ConnectionSpec } from './connection.ts' +import type { ConnectionSpec, ConnectionWriter } from './connection.ts' import type { HostSource } from './host.ts' import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' import { @@ -58,9 +58,10 @@ export class LspInstance { /** * @param spec - the launch, initialize, and teardown parameters. + * @param writer - optional connection writer used by transport conformance tests. */ - constructor(private readonly spec: InstanceSpec) { - this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params)) + constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) { + this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer) this.ready = this.initialize() // A handshake rejection must not surface as an unhandled rejection before the first query awaits // it; queries attach the real handler. @@ -70,7 +71,7 @@ export class LspInstance { /** Synchronous liveness check: true once the process has closed or the instance was disposed. */ get dead(): boolean { - return this.processClosed || this.disposed + return this.processClosed || this.disposed || this.connection.failed } /** @@ -272,7 +273,7 @@ export class LspInstance { try { await this.gracefulShutdown(shutdownDeadline.signal) } catch { - // Graceful shutdown failed or timed out; process-group cleanup below remains authoritative. + // Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative. } finally { shutdownDeadline[Symbol.dispose]() } @@ -286,20 +287,20 @@ export class LspInstance { await abortable(this.connection.closed, signal) } - /** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */ + /** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */ private async forceTerminate(): Promise { this.connection.terminate() const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') - let groupExited: boolean + let treeExited: boolean try { - groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal) + treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal) } finally { graceDeadline[Symbol.dispose]() } - if (!groupExited) this.connection.kill() + if (!treeExited) this.connection.kill() await Promise.all([ this.connection.closed, - this.connection.waitForProcessGroupExit(), + this.connection.waitForProcessTreeExit(), ]) } } diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 464848bbf5..18cb7a1bce 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -1,6 +1,17 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-local' +import { + signalProcessGroup, + signalProcessTree, + taskkillProcessTree, +} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import type { + ConnectionWriter, + ProcessSignalRunner, + ProcessTreeOperations, + TaskkillRunner, +} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -125,7 +136,7 @@ describe('LspConnection', () => { }) /** Spawn a raw connection running an inline node script as the "server". */ -function connectScript(script: string, maxStderrBytes = 100_000): LspConnection { +function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection { const conn = new LspConnection({ command: process.execPath, args: ['-e', script], @@ -134,7 +145,7 @@ function connectScript(script: string, maxStderrBytes = 100_000): LspConnection maxMessageBytes: 16_000_000, maxStderrBytes, configuration: null, - }, () => Promise.resolve(null)) + }, () => Promise.resolve(null), writer) open.push(conn) return conn } @@ -209,13 +220,13 @@ describe('LspConnection edge behavior', () => { await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) }) - it.skipIf(process.platform === 'win32')('rejects a pending request when child stdin closes but the process stays alive', async () => { - const conn = connectScript('const stdin=process.stdin; require("node:fs").closeSync(0); stdin._handle?.close(); setInterval(()=>{}, 1000)') - await new Promise(resolve => setTimeout(resolve, 100)) - const timeout = new Promise((_resolve, reject) => { - setTimeout(() => { reject(new Error('request timed out')) }, 1000) - }) - await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/) + it('rejects a pending request when child stdin fails but the process stays alive', async () => { + const failure = new Error('fixture stdin failure') + const writer: ConnectionWriter = (_stdin, _message, done) => { + queueMicrotask(() => { done(failure) }) + } + const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer) + await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/) }) it('ignores a frame that is neither a valid request nor a numeric-id response', async () => { @@ -230,6 +241,55 @@ describe('LspConnection edge behavior', () => { }) }) +describe('process-tree signaling', () => { + it('forwards POSIX process-group signals through the host runner', () => { + const run: ProcessSignalRunner = vi.fn(() => true) + signalProcessGroup(-42, 'SIGKILL', run) + expect(run).toHaveBeenCalledWith(-42, 'SIGKILL') + }) + + it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => { + const operations = fakeProcessTreeOperations() + signalProcessTree('win32', 42, 'SIGTERM', operations) + expect(operations.taskkill).toHaveBeenCalledWith(42) + expect(operations.signal).not.toHaveBeenCalled() + + signalProcessTree('linux', 42, 'SIGKILL', operations) + expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL') + }) + + it('falls back to the direct child and tolerates an already-dead child', () => { + const fallback = fakeProcessTreeOperations() + vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') }) + signalProcessTree('win32', 42, 'SIGTERM', fallback) + expect(fallback.killChild).toHaveBeenCalledWith('SIGTERM') + + const gone = fakeProcessTreeOperations() + vi.mocked(gone.signal).mockImplementation(() => { throw new Error('group gone') }) + vi.mocked(gone.killChild).mockImplementation(() => { throw new Error('child gone') }) + expect(() => { signalProcessTree('linux', 42, 'SIGKILL', gone) }).not.toThrow() + }) + + it('runs taskkill for the full tree and rejects command failures', () => { + const success: TaskkillRunner = vi.fn(() => ({ status: 0 })) + taskkillProcessTree(42, success) + expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' }) + + const spawnFailure = new Error('cannot spawn taskkill') + expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure) + expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/) + }) +}) + +/** Create observable process-tree operations without touching host processes. */ +function fakeProcessTreeOperations(): ProcessTreeOperations { + return { + signal: vi.fn(), + killChild: vi.fn(), + taskkill: vi.fn(), + } +} + /** Poll a predicate until it holds or a deadline elapses. */ async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { const start = Date.now() diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 2b7fb76b2f..1a30ed5628 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -16,8 +16,6 @@ * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path. * - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received. * - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized. - * - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes the stdin pipe after initialization. - * - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes the stdin pipe before the first query response. * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of @@ -28,7 +26,7 @@ * Run: node fixture-server.ts (Node's erasable TypeScript syntax support). */ -import { appendFileSync, closeSync } from 'node:fs' +import { appendFileSync } from 'node:fs' const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 @@ -40,8 +38,6 @@ const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0) const openMarker = process.env.LSP_FAKE_OPEN_MARKER const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1' -const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1' -const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1' const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) const exitMarker = process.env.LSP_FAKE_EXIT_MARKER const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' @@ -146,14 +142,12 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul if (method === 'initialized') { if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n') if (pauseStdinAfterInitialized) process.stdin.pause() - if (closeStdinAfterInitialized) closeStdinPipe() return } if (method === 'textDocument/didClose') return if (method?.startsWith('textDocument/')) { if (hang) return const reply = (): void => { - if (closeStdinAfterReply) closeStdinPipe() if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }) } else { @@ -171,13 +165,6 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul if (id !== undefined) send({ id, result: null }) } -/** Close both the CRT descriptor and libuv handle that can own a platform's child-stdin pipe. */ -function closeStdinPipe(): void { - const stdin = process.stdin as NodeJS.ReadStream & { _handle?: { close(): void } } - closeSync(0) - stdin._handle?.close() -} - /** Append one teardown event when the fixture is configured to expose process ordering. */ function markExit(event: string): void { if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`) @@ -209,6 +196,6 @@ function send(message: Record): void { // Keep the event loop alive. process.stdin.resume() -if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) { +if (pauseStdinAfterInitialized) { setInterval(() => {}, 1000) } diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 343233c4f5..cfa8120dbc 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' +import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' +import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' @@ -26,7 +28,11 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -function makeInstance(env: Record = {}, overrides: Partial = {}): LspInstance { +function makeInstance( + env: Record = {}, + overrides: Partial = {}, + writer?: ConnectionWriter, +): LspInstance { const instance = new LspInstance({ command: process.execPath, args: [fixtureServer], @@ -39,7 +45,7 @@ function makeInstance(env: Record = {}, overrides: Partial { expect(instance.dead).toBe(true) }) - it.skipIf(process.platform === 'win32')('terminates when stdin fails during the didOpen write', async () => { - // Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose; - // the instance must still become dead so its provider can replace it. - await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000)) - const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, { + it('terminates when stdin fails during the didOpen write', async () => { + const instance = makeInstance({}, { shutdownTimeoutMs: 100, killGraceMs: 100, - }) + }, failingWriter('textDocument/didOpen')) await expect(run(instance, 'goToDefinition')).rejects.toThrow() expect(instance.dead).toBe(true) }) @@ -225,11 +228,10 @@ describe('LspInstance query and abort', () => { await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/) }) - it.skipIf(process.platform === 'win32')('keeps a settled result but awaits teardown when didClose cannot be written', async () => { + it('keeps a settled result but awaits teardown when didClose cannot be written', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null', - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1', - }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + }, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose')) await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], @@ -281,7 +283,7 @@ describe('LspInstance disposal', () => { await expect(instance.dispose()).resolves.toBeUndefined() }) - it.skipIf(process.platform === 'win32')('awaits a surviving process-group helper on every concurrent dispose', async () => { + it('awaits a surviving process-tree helper on every concurrent dispose', async () => { const marker = join(root, 'helper.pid') const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' @@ -298,6 +300,7 @@ describe('LspInstance disposal', () => { await first } finally { if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL') + await waitForProcessExit(helperPid) } }) @@ -322,6 +325,26 @@ function processAlive(pid: number): boolean { } } +/** Wait until a process id disappears so temporary-workspace cleanup cannot race handle release. */ +async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise { + const started = Date.now() + while (processAlive(pid)) { + if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +/** Write normally except for one method whose callback receives a deterministic transport error. */ +function failingWriter(method: string): ConnectionWriter { + return (stdin, message, done) => { + if ((message as { method?: unknown }).method === method) { + queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) }) + return + } + stdin.write(encodeMessage(message), done) + } +} + /** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */ async function waitForFile(path: string, timeoutMs = 3000): Promise { const started = Date.now() diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 7ba76d03de..826905ed26 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -237,7 +237,7 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) - it.skipIf(process.platform === 'win32')('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => { + it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => { // The first query succeeds, then the server exits before the second arrives, leaving a dead // instance in the pool. The next query must evict-and-replace it and still succeed, rather than // failing once on the closed connection first. diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index fc4f92dfad..2b48e9de1f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -198,7 +198,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) - it.skipIf(process.platform === 'win32')('renders its header, footer, replay, streaming answer, todos, and status', async () => { + it('renders its header, footer, replay, streaming answer, todos, and status', async () => { let now = 0 const result = await setup({ contextWindow: 100, @@ -320,7 +320,9 @@ describe('pi-tui chat lifecycle and transcript', () => { { inputTokens: 500, outputTokens: 8 }, { turn: 3, step: 1 }, ) - await tick() + await vi.waitFor(() => { + expect(result.terminal.output).toContain('final live answer') + }) expect(result.terminal.output).toContain('◒ Working · 8s') expect(result.terminal.output).toContain('esc interrupt') @@ -328,7 +330,6 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('user context') expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') - expect(result.terminal.output).toContain('final live answer') expect(result.terminal.progress).toContain(true) result.session.append('assistant/chunk', { diff --git a/vitest.config.ts b/vitest.config.ts index 8baa7c7b32..c5b5c06d11 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,17 +11,6 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] -// These files retain 100% per-file coverage on POSIX, where their process-pipe and terminal timing -// tests are deterministic; Windows skips those cases and must not fail solely on their uncovered paths. -const windowsCoverageExclusions = process.platform === 'win32' - ? [ - 'packages/lsp/lsp-local/src/connection.ts', - 'packages/lsp/lsp-local/src/index.ts', - 'packages/lsp/lsp-local/src/instance.ts', - 'packages/ui/tui/src/index.ts', - ] - : [] - export default defineConfig({ // Native path resolution reads each package's nearest tsconfig, but only the root defines // workspace paths. Keep this plugin pinned to the root map so unbuilt bare package imports resolve @@ -44,7 +33,6 @@ export default defineConfig({ 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), - ...windowsCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. From 7b6b544243b1109d23a21e7e10d9db1cd62d2389 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:34:26 +0800 Subject: [PATCH 02/21] test(windows): cover teardown decisions deterministically --- packages/lsp/lsp-local/src/connection.ts | 25 +++++++++++++++---- packages/lsp/lsp-local/src/index.ts | 4 --- packages/lsp/lsp-local/src/instance.ts | 11 +++++++- .../lsp/lsp-local/tests/connection.spec.ts | 14 +++++++++++ packages/lsp/lsp-local/tests/instance.spec.ts | 11 +++++++- 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 201cd87bdd..7ed3e09054 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -110,6 +110,25 @@ export function signalProcessGroup( run(target, signal) } +/** + * Wait until a process-tree liveness probe reports exit. + * @param isAlive - process-tree liveness probe. + * @param signal - optional bound for the wait. + * @param yieldNow - event-loop yield primitive. + * @returns `true` when the tree exited, or `false` when the signal aborted first. + */ +export async function waitForTreeExit( + isAlive: () => boolean, + signal?: AbortSignal, + yieldNow: () => Promise = yieldToEventLoop, +): Promise { + while (isAlive()) { + if (signal?.aborted) return false + await yieldNow() + } + return true +} + /** * Signal a detached process tree with platform-correct semantics and a direct-child fallback. * @param platform - host platform. @@ -269,11 +288,7 @@ export class LspConnection { * @returns `true` when the tree exited, or `false` when the signal aborted first. */ async waitForProcessTreeExit(signal?: AbortSignal): Promise { - while (this.processTreeAlive()) { - if (signal?.aborted) return false - await yieldToEventLoop() - } - return true + return await waitForTreeExit(this.processTreeAlive.bind(this), signal) } /** diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 340d5a4df0..7a4b1b6b6d 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -221,10 +221,6 @@ class LocalLspProvider implements LspProvider { // synchronous get-or-create so every spawned process remains owned by teardown. this.assertActive(signal) let instance = this.instanceFor(workspace) - if (instance.dead) { - this.evictIfCurrent(workspace, instance) - instance = this.instanceFor(workspace) - } try { return await instance.query(request, source, signal) } catch (error) { diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 02bdd160d4..eecc5d4e5e 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -39,6 +39,15 @@ export interface InstanceSpec extends ConnectionSpec { readonly killGraceMs: number } +/** + * Force-kill a process tree only when graceful termination did not make it exit. + * @param treeExited - whether the tree exited within its grace period. + * @param forceKill - forceful process-tree termination primitive. + */ +export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void { + if (!treeExited) forceKill() +} + /** * A single initialized server process. Not exported as a provider — the provider single-flights and * pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down. @@ -297,7 +306,7 @@ export class LspInstance { } finally { graceDeadline[Symbol.dispose]() } - if (!treeExited) this.connection.kill() + escalateProcessTree(treeExited, this.connection.kill.bind(this.connection)) await Promise.all([ this.connection.closed, this.connection.waitForProcessTreeExit(), diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 18cb7a1bce..9fea82b43f 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -5,6 +5,7 @@ import { signalProcessGroup, signalProcessTree, taskkillProcessTree, + waitForTreeExit, } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' import type { ConnectionWriter, @@ -248,6 +249,19 @@ describe('process-tree signaling', () => { expect(run).toHaveBeenCalledWith(-42, 'SIGKILL') }) + it('waits for tree exit and stops when its bound aborts', async () => { + const isAlive = vi.fn() + .mockReturnValueOnce(true) + .mockReturnValue(false) + const yieldNow = vi.fn(() => Promise.resolve()) + await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true) + expect(yieldNow).toHaveBeenCalledOnce() + + const controller = new AbortController() + controller.abort() + await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false) + }) + it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => { const operations = fakeProcessTreeOperations() signalProcessTree('win32', 42, 'SIGTERM', operations) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index cfa8120dbc..d08a431192 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,6 +6,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' @@ -242,6 +243,14 @@ describe('LspInstance query and abort', () => { }) describe('LspInstance disposal', () => { + it('escalates only when the process tree survives its grace period', () => { + const forceKill = vi.fn() + escalateProcessTree(false, forceKill) + expect(forceKill).toHaveBeenCalledOnce() + escalateProcessTree(true, forceKill) + expect(forceKill).toHaveBeenCalledOnce() + }) + it('lets a server finish protocol exit before signal escalation', async () => { const marker = join(root, 'graceful-exit.log') const instance = makeInstance({ From 2a8e7c661d2a9937993e1a8d67fa47818d8e1990 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:42:11 +0800 Subject: [PATCH 03/21] test(windows): use native PATH delimiter --- packages/lsp/lsp-local/tests/provider.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 7a969781f3..829a84264b 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, join } from 'node:path' import { Context } from 'cordis' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' @@ -57,7 +57,7 @@ describe('lsp-local provider resolution', () => { await expect(ctx.plugin(LspLocal, config('nope', { command: 'fake-lsp', args: [], - env: { PATH: `::${join(root, 'empty')}` }, + env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` }, extensionToLanguage: { '.ts': 'typescript' }, }))).rejects.toThrow(/was not found on PATH/) await ctx.fiber.dispose() From 769710cfb990fa93376cfaa3e9042be38f028f9f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:56:29 +0800 Subject: [PATCH 04/21] fix(lsp): make transport recovery ownership-safe --- ...-22-cross-platform-test-fixtures.i18n.yaml | 4 +-- ...2026-07-22-cross-platform-test-fixtures.md | 4 +-- ...6-07-22-cross-platform-test-fixtures.zh.md | 4 +-- packages/lsp/lsp-local/README.md | 4 +-- packages/lsp/lsp-local/src/connection.ts | 30 ++++++++++++++----- packages/lsp/lsp-local/src/index.ts | 18 ++++++----- packages/lsp/lsp-local/src/instance.ts | 16 +++++++++- .../lsp/lsp-local/tests/connection.spec.ts | 28 +++++++++++------ packages/lsp/lsp-local/tests/instance.spec.ts | 11 +++++++ .../lsp/lsp-local/tests/lifecycle.spec.ts | 10 +++++-- 10 files changed, 94 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml index 511b66e345..f5fc9ecef6 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml @@ -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 -2026-07-22-cross-platform-test-fixtures.md: 56deaf6306e15c6cf17e83fcbaf36137e5c543f4 -2026-07-22-cross-platform-test-fixtures.zh.md: f61441e2dbe86fa5a580e666fcd08d23b0fadf0b +2026-07-22-cross-platform-test-fixtures.md: 6217aabfdbe8f14f869004c8dafb7e19f4b7443a +2026-07-22-cross-platform-test-fixtures.zh.md: 43942ec0468df822d04b39e318010c2b260c734f diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md index 56deaf6306..6217aabfdb 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md @@ -16,7 +16,7 @@ Tests of platform-neutral behavior construct absolute paths and `file:` URIs wit Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles. -Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows, with a direct-child fallback when the tree is already gone. A read-only provider query retries once only when its pooled transport becomes dead after the liveness check; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. +Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows. Windows suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files. @@ -30,4 +30,4 @@ Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on tha ## Consequences -Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns. +Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer. diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md index f61441e2db..43942ec046 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md @@ -16,7 +16,7 @@ Status: implemented 传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。 -语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`;若进程树已经不存在,则回退到直接终止子进程。只读的提供方查询仅在池化传输于存活检查后失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 +语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;同步取得命令结果让 dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出。 +可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,dispose 的调用方仍能观察到该失败。 diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 269fe6b666..7c6c05b7df 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -7,10 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). ## What it does - Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. -- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the transport becomes dead between the pool's liveness check and a read-only query, the provider evicts it and retries that query once on a fresh process. +- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. -- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`, with a direct-child fallback for teardown races. +- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 7ed3e09054..1103c4dbd2 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -52,7 +52,7 @@ export type ConnectionWriter = ( export interface ProcessTreeOperations { /** Signal a POSIX process group. */ readonly signal: (target: number, signal: NodeJS.Signals) => void - /** Signal the direct child when group/tree signalling is unavailable. */ + /** Signal the direct child when POSIX group signaling is unavailable. */ readonly killChild: (signal: NodeJS.Signals) => void /** Terminate a Windows process tree by root pid. */ readonly taskkill: (pid: number) => void @@ -78,6 +78,9 @@ export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => bo const processSignalRunner: ProcessSignalRunner = process.kill.bind(process) +/** taskkill status for "process not found": the requested process tree is already absent. */ +const TASKKILL_TREE_NOT_FOUND_STATUS = 128 + const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => { stdin.write(encodeMessage(message), done) } @@ -93,6 +96,7 @@ export function taskkillProcessTree( ): void { const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) if (result.error !== undefined) throw result.error + if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`) } @@ -130,7 +134,8 @@ export async function waitForTreeExit( } /** - * Signal a detached process tree with platform-correct semantics and a direct-child fallback. + * Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct + * child; Windows requires taskkill to reach the full tree. * @param platform - host platform. * @param pid - detached root process id. * @param signal - requested termination signal. @@ -142,9 +147,12 @@ export function signalProcessTree( signal: NodeJS.Signals, operations: ProcessTreeOperations, ): void { + if (platform === 'win32') { + operations.taskkill(pid) + return + } try { - if (platform === 'win32') operations.taskkill(pid) - else operations.signal(-pid, signal) + operations.signal(-pid, signal) } catch { try { operations.killChild(signal) @@ -219,6 +227,15 @@ export class LspConnection { return this.closeReason !== undefined } + /** + * Test whether a caught error is this connection's retained fatal transport cause. + * @param error - error caught by the instance or provider. + * @returns `true` only when this connection produced that exact failure. + */ + failedWith(error: unknown): boolean { + return this.closeReason === error + } + /** * Send a request and await its result. * @param method - the JSON-RPC method. @@ -291,10 +308,7 @@ export class LspConnection { return await waitForTreeExit(this.processTreeAlive.bind(this), signal) } - /** - * Signal the whole process tree so helper processes are reached; fall back to the direct child if - * tree signaling fails. Never throws because teardown races process exit. - */ + /** Signal the whole process tree. */ private signalTree(sig: NodeJS.Signals): void { const pid = this.child.pid if (pid === undefined) return diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 7a4b1b6b6d..dda3558130 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -2,8 +2,8 @@ * Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table * of server commands and registers one isolated provider for each entry. Every provider lazily * single-flights one server process per canonical workspace realpath, serves transient-open queries - * through it, and replaces a transport that dies between a pool liveness check and the next - * read-only query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`) + * through it, and replaces a selected transport that fails before or during the next read-only + * query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`) * and trust their configured servers — no sandbox confinement. * * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal @@ -224,16 +224,20 @@ class LocalLspProvider implements LspProvider { try { return await instance.query(request, source, signal) } catch (error) { - // A child can die after the pre-query liveness check but before or during the next write. - // Queries are read-only, so replace a newly failed transport once and retry transparently. - if (!instance.dead) throw error + // A selected child can have died while idle or fail during the next write. Queries are + // read-only, so replace that transport once and retry transparently. + if (!instance.isTransportFailure(error)) throw error + await instance.dispose() this.evictIfCurrent(workspace, instance) this.assertActive(signal) instance = this.instanceFor(workspace) return await instance.query(request, source, signal) } finally { - // Drop a crashed slot only when it still owns this instance; a replacement must survive. - if (instance.dead) this.evictIfCurrent(workspace, instance) + // Reach quiescence before dropping a dead slot; a replacement must survive this ownership check. + if (instance.dead) { + await instance.dispose() + this.evictIfCurrent(workspace, instance) + } } }) } diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index eecc5d4e5e..266dd3c59f 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -83,6 +83,15 @@ export class LspInstance { return this.processClosed || this.disposed || this.connection.failed } + /** + * Test whether a caught query error came from this instance's transport. + * @param error - error caught by the provider. + * @returns `true` only for the connection's retained fatal transport cause. + */ + isTransportFailure(error: unknown): boolean { + return this.connection.failedWith(error) + } + /** * Run one query through the serialized queue. * @param request - the resolved provider query. @@ -94,7 +103,12 @@ export class LspInstance { // Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query // hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up // rather than block on the shared tail forever. - const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)) + const run = abortable(this.queue, signal) + .then(() => this.runQuery(request, source, signal)) + .catch(async (error: unknown) => { + if (this.isTransportFailure(error)) await this.startTeardown() + throw error + }) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up // on the wait does not deserialize the queue. diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 9fea82b43f..aa7e819cb6 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -65,6 +65,12 @@ describe('LspConnection', () => { await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) }) + it('treats signaling an already-closed child as a teardown race', async () => { + const conn = connectScript('') + await conn.closed + expect(() => { conn.kill() }).not.toThrow() + }) + it('answers a server workspace/configuration request from static config', async () => { const seen: SeenRequest[] = [] const conn = connect( @@ -272,23 +278,27 @@ describe('process-tree signaling', () => { expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL') }) - it('falls back to the direct child and tolerates an already-dead child', () => { + it('surfaces a Windows taskkill failure without downgrading to the direct child', () => { const fallback = fakeProcessTreeOperations() vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') }) - signalProcessTree('win32', 42, 'SIGTERM', fallback) - expect(fallback.killChild).toHaveBeenCalledWith('SIGTERM') - - const gone = fakeProcessTreeOperations() - vi.mocked(gone.signal).mockImplementation(() => { throw new Error('group gone') }) - vi.mocked(gone.killChild).mockImplementation(() => { throw new Error('child gone') }) - expect(() => { signalProcessTree('linux', 42, 'SIGKILL', gone) }).not.toThrow() + expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/) + expect(fallback.killChild).not.toHaveBeenCalled() }) - it('runs taskkill for the full tree and rejects command failures', () => { + it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => { + const posixGone = fakeProcessTreeOperations() + vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') }) + vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') }) + expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow() + }) + + it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => { const success: TaskkillRunner = vi.fn(() => ({ status: 0 })) taskkillProcessTree(42, success) expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' }) + expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow() + const spawnFailure = new Error('cannot spawn taskkill') expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure) expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index d08a431192..9f246e602a 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -216,6 +216,17 @@ describe('LspInstance query and abort', () => { expect(instance.dead).toBe(true) }) + it('awaits process exit before rejecting a request write failure', async () => { + const instance = makeInstance({}, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }, failingWriter('textDocument/definition')) + // The pid is observed only to prove the owned subprocess reached quiescence before rejection. + const pid = (instance as unknown as { connection: { pid: number } }).connection.pid + await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/) + expect(processAlive(pid)).toBe(false) + }) + it('rejects when the server lacks the operation capability', async () => { const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 826905ed26..47b8d78bb4 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -122,9 +122,15 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) - it('rejects a non-utf-16 position encoding at initialize', async () => { - const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) + it('rejects a non-utf-16 position encoding at initialize without retrying', async () => { + const marker = join(root, 'initialize-rejection-exit.log') + const ctx = await mount({ + LSP_FAKE_ENCODING: 'utf-8', + LSP_FAKE_DEF: 'null', + LSP_FAKE_EXIT_MARKER: marker, + }) await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) + expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n') await ctx.fiber.dispose() }) From a6a3807a07d39c1cd066679a5ccc0d37db65ccb6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:17:57 +0800 Subject: [PATCH 05/21] =?UTF-8?q?feat(gui):=20step1=20skeleton=20=E2=80=94?= =?UTF-8?q?=20dsc=20web=20serves=20built=20web=20UI=20over=20booted=20harn?= =?UTF-8?q?ess=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new modules: apps/dsc (bin: parseArgs + node:http static server + signal shutdown), packages/host/apiproxy (programmatic harness core composition, agents:[]), packages/client/web-runtime (React-free browser runtime), packages/client/web-ui (React mount), apps/web (vite build entry producing dist consumed by apps/dsc via package exports). Root wiring: apps/* workspace glob, dsh-* paths for host/client groups, demo:web script, apps/web/dist gitignore. No protocol/API routes yet — contract lands in step2 (see missions/tasks/20260719-1902-apiproxy-api-design). Includes the design + implementation archives (spec v2.1, deepseekchat baseline and harness boot research, implementation run log). Acceptance: 12/12 passed incl. real-key llm.stream smoke (51 chunks). feat(gui): apiproxy — four-quadrant RPC contract + fetch carriers, live end to end Contract layer (src/api/, 14 files): four named wire message types (ClientRequest / ServerResponse / ServerRequest / ClientResponse) as a discriminated union over strict bidirectional rpcId (initiator mints, responder echoes; channel and message fully decoupled — HTTP is the client->server pipe, SSE the reverse); narrow RpcRequest

/ RpcResponse signature forms; RpcMethodMap with RequestPayload/ ResponseValue derivation; typed RpcError details map; approval/ question responses modeled as ClientResponse via a single /api/respond endpoint (RpcReceipt carrier ack); zod schemas anchored per Wire against exactOptionalPropertyTypes. impl/api-proxy.ts: describe/list/create, both SSE streams (frame queue pump, subscribed baseline, lifecycle frames, signal cleanup); history pages on message boundaries (tail-back scan, partial included in the tail page); prompt dispatches queue->agent.send / steer->agent.steer with rpcId carried through MessageSource; cancel for attached sessions; cold-session resume deduped via a per-id promise map; host-level provider/model defaults injected at create/resume. fetch/: mechanical UNARY_ROUTES table, two-level parse with path==method check, SSE frames completed to ServerRequest full form; client mints -> narrows -> envelopes outbound, verifies rpcId echo inbound, streams SSE frames, four-quadrant onEnvelope tap (debug panel choke point). Real-browser fixes: URL base resolves to location.origin (hardcoded internal base broke real pages), browser-safe export paths. Design archives: contract design.md v2.0 with decision log, core-coverage audit, comparative studies, step2 impl run log. Probed end to end over real HTTP: prompt -> live model stream -> history returns the finished reply. feat(gui): RpcLog debug panel — fixture-driven milestone, playwright-verified 10/10 web-runtime: rpcLog + ui slices (zustand), four-quadrant RpcLogEntry (client-request / server-response / server-request / client-response), onEnvelope tap -> microtask-batched pump with 500-entry ring buffer, ConnectionController (private state, backoff reconnect), fixture API with fake envelopes (?fixture switch), bootWebRuntime; contract types via temporary local copies (api-types.ts, swapped for real imports when W3 client lands). web-ui: components/panels/RpcLog five-piece set (badge with unread count, floating panel, direction glyphs per quadrant, same-rpcId pair highlighting in two families, JSON payload expand, follow/pause, clear), App shell, utils/formatRelative, light-theme CSS variables with dark placeholders. dsc bin: mime lookup fixed to use the actually-served file (naked '/?query' no longer falls through to octet-stream download); shutdown closes SSE keep-alive connections so SIGTERM actually exits. Acceptance: scripts/verify-rpclog-panel.mjs (chromium headless) ALL PASS 10/10 over design.md §D 1-6. pkg: add web scripts for building feat(gui): session milestone — list + conversation over Session OOP, styled RpcLog v2.1 web-runtime: Session/SessionManager object layer (resident instances, mux frame routing, lineage flattening), foldSurface adapter with padding sentinels for paged windows, chunk accumulator for streaming partials, batched change notification (useSyncExternalStore contract), connection sinks + reconnect fix (the 300ms self-abort reconnect storm that made the session list flap is gone), fixture rewritten as a scripted host (60-turn history, typewriter replay, resident pending approval, child session); temporary contract copies deleted in favor of real apiproxy imports. web-ui: sessions screen (list with lineage indent + selection as container-local state), conversation view (turn grouping, reasoning fold, tool cards, steering, pending interaction cards, upward paging with scroll anchoring), input bar with queue/steer/stop; RpcLog panel restyled per docs/web-styling.md (tokenized palette, quadrant badge glyphs now vertical ↑↓⇟⇞, pair highlighting, floating shadow). docs/web-styling.md: living style guide (tokens, visual baseline, coding rules, evolution log). Acceptance: verify-session.mjs 31/31, verify-session-real.mjs 5/5 (real model streaming), verify-rpclog-panel.mjs 10/10. feat(gui): hostruntime split + repo-wide package prefix rename Package split (design: 20260720-0101-hostruntime-split-design): dsh-host-runtime carries bootHost + createApiProxy + startHost() (RunningHost {api, handler, defaults, ctx, dispose} — the seam Electron and any future shell reuses; ctx is the official front-door mount point); dsh-host-webserver carries the node:http static+API bridge (fixed: abort now keys on res 'close' + writableEnded — req 'close' fires on body end since Node 16 and was killing every SSE stream instantly, the reconnect-storm root cause); apps/dsc is now a thin assembly with web/-p subcommands. dsc -p runs the full isomorphic carrier chain in process (second real protocol consumer; probed end-to-end against the live model). Naming rule (user decree): packages under host/ and client/ carry the directory prefix in their npm name — dsh-host-apiproxy, dsh-client-web-runtime, dsh-client-web-ui renamed repo-wide in one frozen batch; explicit tsconfig paths entries added where the wildcard no longer matches. Acceptance: verify-session 31/31, verify-rpclog-panel 10/10, verify-session-real 7/7 (incl. new 12s connection-stability sentinels), tsc green, dsc web + dsc -p smoke both pass. refactor(gui): AbstractApiClient class hierarchy — OO client with inheritable seams AbstractApiClient (apiproxy) carries every protocol invariant: rpcId minting, four-quadrant envelope wrap/unwrap, zod parsing, SSE frame parsing, the payload-direct IApiClient surface (callers no longer mint rpcIds — the carrier does), and the instance-level envelope observation pump (batched via microtask; moved off module-level globals in rpc-log.ts, which is now a pure subscriber mapping envelopes into store entries — the debug panel observes the connection, it is not part of it). Platform subclasses own two abstract seams (doFetch, onEnvelope) plus three protocol-level virtuals for transportless overrides: InProcessApiClient (apiproxy; dsc -p uses new InProcessApiClient( host.handler)), WebApiClient (web-runtime), FixtureApiClient (fixture now subclasses instead of wrapping). Naming per decree: AbstractApiClient / IApiClient; ApiProxy stays the impl-side narrow-form contract. headless.ts call sites drop rpcRequest wrappers (payload-direct); split-design archive updated with the naming-rule ledger. tsc green; verify-session 31/31, verify-rpclog-panel 10/10, verify-session-real 7/7 (12s connection sentinel count=4); dsc -p smoke CALLER-OK. feat(gui): InputBar final form — bug batch, deepseekchat layout, single primary button, running locks input Squashes the whole InputBar iteration batch: IME/caret/auto-grow/focus/dedup bug fixes, layout aligned to the deepseekchat baseline, single primary button with hover flyout, finalized button semantics with the Codex-style icon circle, and running-state locking where stop is the only mid-turn action. The same batch carried the Chinese-to-English code comment sweep (density pruned), folded in here. docs(gui): purge work-log references from code comments 76 design-doc references cleared across the GUI packages: section pointers inlined as self-contained constraint statements, pure pointer comments dropped, milestone codenames and ruling tags out, and the 14 contract file headers switched to the formal RFC (the only sanctioned external reference). web-styling.md now cites the styling RFC instead of the disposable research archive. grep for work-log reference variants is clean across the GUI packages. docs(gui): file-header comments self-contained — drop RFC filename references RFC renames/reorgs must not require a source sweep (the 2026-07-20 two-way merge proved it). 11 headers lose only the '(RFC …)' tail and stay self-contained; api-proxy.ts keeps its minimal-first note. fix(gui): session streaming — freeze interrupted partials, sweep stale running calls, send force-scrolls Aborted turns never emit the finalizing assistant/message, so the accumulated partial and its running tool cards kept rendering below later messages — the "new message lands above the stopped reply" illusion. turn/end side effects now freeze content-bearing partials into interrupted terminal nodes (fractional seq keeps flow order; the live freeze and history replay converge through applyEventSideEffects, so a refresh reconstructs identical frozen nodes) and turn running tool cards into interrupted terminal cards; only content-free partials are swept outright. ConversationView gains the send-force-scroll rule (own words must be visible) alongside the pre-update atBottom follow flag. Regressions pinned as E2-4a–c (real host) and §E1-11h (fixture). feat(gui): webserver hardening verify script feat(gui): dark-mode toggle pinned to the sidebar bottom Interim home before the Settings page exists (the button re-homes with zero logic change — mechanics live in utils/theme.ts): html[data-theme] flip + dsc.theme localStorage, stored choice wins over the OS prefers-color-scheme default, applied in mount() before first paint so a dark reload never flashes light. Moon/sun inline SVG icon button at the sidebar's pinned bottom row. Pure front-end local concern: no RPC, no Session/store involvement. Dark sweep of list/conversation/input card/RPC panel found no unreadable pairs — no token changes needed. docs(gui): GUI RFCs and web styling handbook Layering+RPC protocol and web client architecture RFCs (post-reorg, developer-facing polish folded in) plus the styling engineering handbook. Mission work logs live in the commit above; PRs can be cut from this commit to include formal docs only. fix(gui): client object-layer hardening — audit timing/reference/resilience batches (S3-S5,C1-C3,C5-C8) fix(gui): carrier error channel + webserver backpressure (audit A1-A5,A7-A10,R2,R5) feat(gui): session persistence surface — cold list, project cwd, legacy no-cwd retirement refactor: rename dsc CLI to dsh — apps/cli, bin name, package scope Includes the root tsconfig project-references fix for host/* and client/web-runtime (originally a separate build fix commit). test(gui): three-tier suite — protocol/object/browser lanes, tier-a fill to per-file 100% test(gui): jsdom lane for web-ui + web-runtime coverage gate entry docs(gui): GUI testing system RFC (zh) feat(gui): tool-card views — contract slot, host-computed delivery, three-level card fallback fix(gui): lint clean across GUI packages — wrap long doc comments, drop dead type args, sync-return methods without awaits docs(gui): doc-sync mechanical fixes — JSDoc on apiproxy/host exports, RFC sketch fences ignore-check, md-wrap paragraphs, drop missions links, web-ui plain-ts entry chore(gui): module-graph regen + knip clean — drop dead re-exports, internalize createFixtureApi, scan web-ui tsx and verify mjs scripts build(gui): wire client/host packages into the lib build shape — tsc references + tsdown (web-ui css-external), lib manifests, cordis peer, apiproxy typed subpaths, vite src aliases test(gui): host-side per-file 100% coverage — apiproxy schema/carrier suites, webserver http-bridge suite, host-runtime composition suite; client/* coverage excluded pending the browser-side testing work item docs(gui): package READMEs for the five GUI packages — model-experience audit entries, limitations sections docs(gui): bilingual RFC pairs + client JSDoc completion — translate the three GUI RFCs to English with i18n records and manifest ratchet, Consequences sections both sides, full client/* export JSDoc, regen doc graphs and RFC index fix(scripts): doc-typecheck built-declarations mode maps /src/* subpath wildcards (apiproxy browser-safe channels) docs(gui): apply dsh rename across pr-gates docs — READMEs, layering RFC en, web-ui entry comment, i18n re-record fix(gui): post-rebase lint reconciliation — wrap main-tree long doc comments, read-through narrowing guards, abortError Error normalization, handleUnary generic justification fix(gui): post-rebase doc/test reconciliation — align host specs with evolved carrier contracts (sentinel rpcId, stream/error surfacing, url-path transport messages, defaults.cwd), Agent Note titles and relocated links, KV Cache effect sections, JSDoc on evolved exports fix(gui): second-rebase reconciliation to 509db0cb3 — restore api panel exports the baseline suites consume, knip workspace entries for jsdom lane and apps/web smokes, hoist result narrowing, align testing.md to the narrowed web-ui exclusion fix(test): vitest-scoped tsconfig maps bare imports for tsx specs — with GUI manifests now pointing at lib, an unmapped importer loaded a second copy of the web-runtime singletons fix(gui): typecheck + lint clean over the tool-card batch — brand callIds and object-form turn/end reason in the view spec, narrow fixture arg stringification, wrap long v8-ignore comments docs(gui): export JSDoc for tool-card surfaces + testing-note pairing header docs: rfc for web testing feat: add tools to host-runtime fix(gui): dispatch agent/error via agentEvents in host-runtime spec — mounted invariants plugin rejects raw ctx.emit without the scope carrier fix(gui): restore GUI knip workspaces + scripts/mjs entries and regenerate lockfile after master rebase fix(gui): post-rebase gate repairs — drop context-node envelope (master unwrapped injected content envelopes), regen event matrix, condense testing.md web-ui exclusion within budget fix(session): browser-safe deep-equal in surface — node:util import broke the vite bundle ci(gates): frontend vite build joins pre-push — node: imports in the client closure pass tsc but break the browser bundle test(tui): drop the checkout-dependent process.cwd() harness default — a long worktree path pushes the footer token counters past the 88-column fake terminal test(gui): jsdom behavior E2E — conversation main path over fixture runtime, reconnect banner lifecycle test(gui): jsdom RPC panel behavior — ledger rows, expand, pairing, pause/clear, follow-pause, payload truncation test(gui): jsdom tier-2 — InputBar guards, reasoning fold, JSON blocks, message variants, theme, create-then-select; act-harden banner case test(gui): jsdom tier-3 — ConversationView states/paging/force-bottom, ToolCallCard arms, PendingCard, list rows test(gui): jsdom tails — view-card variants, LogRow directions, registry hygiene, badge overflow, hook ops, mount glue test(gui): jsdom tails round 2 — call-ref blocks, resume follow, view precedence, failed create, empty-diff arm test(gui): jsdom final arms — anchor compensation, follow-off, interval ticks, view halves, node-over-running precedence test(gui): web-ui joins the per-file 100% coverage gate Annotation-only src changes plus the config swap. The web-ui exclusion is replaced by a single index.tsx entry (stale byte-identical duplicate of mount.tsx, nothing imports it; same entry-glue treatment as bin.ts) and the coverage include gains .tsx. v8-ignore sites (each with its reason inline): - ConversationView 3x ref-null guards; InputBar disabled-click guard - ToolCallCard both-null arms + windowless-custom argsRaw arm - LogRow css-module key fallbacks (start/stop block); RpcLogBody 3x ref-null guards - web-runtime drift from the tool-card batch: fixture presenter catch/str typo-guards, dense-array guards (fold-adapter reset, session rebuild, fixture backscan), live view-present arm (fixture replays are text-only; view vocabulary is covered by the history samples) test(gui): close the PR #443 host-side coverage gaps — apiproxy client abort arms, api-proxy cold/view paths, webserver drain - apiproxy fetch/client.ts: 3 new cases (pre-aborted signal short-circuits before transport + string reason mapping, non-Error/string reason falls to the default AbortError message, signal-less doFetch passthrough) - runtime/api-proxy.ts: one v8-ignore (summarizeCold cwd arm — list() filters cwd-less legacy metas) + api-proxy-cold.spec.ts (cold list merge: mtime source, locate-undefined and vanished-log fallbacks, lineage; no-persistence/no-factory resume → internal) + 2 view cases (history views with meta passthrough and orphan/bad-args/presenterless soft-falls, session/disposed open-call cleanup on the mux stream) - webserver/index.ts: /api/big fixture drives both drain-wait legs (full 8MiB readback after drain, mid-chunk disconnect wakes via 'close') feat: app shell fix: rebase conflicts fix: coverage fix(gui): lint clean after rebase — wrap long v8-ignore comments, unconditional v1 detail-block claim chore(gui): remove browser/probe verify scripts from scripts/ The six GUI acceptance/probe scripts (carrier-errors, rpclog-panel, session, session-real, webserver-backpressure, webserver-hardening) leave the repo's scripts/ tree; the three code comments that pointed at them now describe the coverage lane without naming a script path. fix(webserver): guard the request callback — one malformed request must not kill the process The async handle() had no top-level catch, so any throw inside it (a bad %-escape reaching decodeURIComponent, a client dropping mid-body, a response stream erroring) became an unhandled rejection and took the whole process down (audit R1 must-fix). The guard answers 400 when headers are not out yet, destroys the socket when they are, and reports the failure to onError (the package never prints). Spec covers all three legs: %-escape barrage → 400 + server stays alive, non-Error throw wrapped for onError, mid-stream explosion → socket teardown. feat: client AGENTS.md fix: client/AGENTS.md fix: rebase feat(gui): T0 cut 1 — 12 client package skeletons with contract stubs, dshClient declarations, tsdown client preset, theme token sheets feat(gui): T0 cut 2 — pure git mv migration per v3 §11 (connection six, runtime sessions/kernel, ui-conversation chat, ui-primitives markdown family, web shell + e2e) feat(gui): T0 cuts 3+4 — import rewiring to new package names, .legacy demotion of owner-rewrite files, legacy web-runtime/web-ui/apps-web retired to attic feat(gui): connection 对账刀——index.ts 精确导出清单替换 export *,intents.legacy 溶解删除 feat(client/ui-slots): SlotCore real implementation — kind semantics, sync version + microtask-batched notify, onMutate bridge feat(gui): web shell vite alias — retarget to new client packages, shell static surface only feat(gui): host 侧刀属地半——HostWebPluginRegistry(entries 扫描+internal/plugin 去抖重扫+dshClient 校验+exports./client 解析)、GET /plugins//client.js 分发端点、GET / 与 SPA fallback 注入 __DSH_BOOT__(webPlugins 可选注入,不传行为不变) feat(web-react): add use-sync-external-store dep + local shim typings feat(web-react): bindSnapshotSelector via uSES with-selector shim feat(gui): ui-layout concession-chain solver — pure computeColumns with contract geometry feat(gui): ui-layout LayoutService — four persisted stores, clamped actions, list-driven prune feat(gui): ui-layout AppFrame styles — grid columns, collapse-safe borders, edge drag handles test(gui): 存量 spec 平移——connection 三件+runtime 六件自 attic 捞回改包名路径全绿;api-helpers 按归属拆分(wire 半留 connection、classifier 半随 conversation.ts 入 runtime);boot-intents/preinit/rpc-log 随 intents/rpc-log 退役不迁(记 v3 §3.2 溶解项) feat(client/ui-primitives): StateDot/Button/Pill/Input/Menu atoms, ConnectionBanner de-legacied to pure props, JsonBlock CSS on --dsw tokens feat(web-react): createSnapshotStore engine (rafFlush batch, persist opt-in, dev freeze) + spec feat(gui): ui-layout AppFrame — grid tracks, pointer-capture drag handles with rAF throttle, frame ResizeObserver feat(web-react): useInvoke (external pending store, stable invoke, concurrency count) + spec test(web-react): bind spec — equality bail, custom eq, zero resubscribe, StrictMode, method sources feat(gui): ui-layout index rewiring — real exports, client apply provides ctx.layout and defines three slots feat(web-react): SessionProvider (renderBody deps) + RootBindingProvider + binding contexts + spec feat(gui): web shell AppRoot boot-page styles — self-contained with neutral token fallbacks feat(gui): web shell AppRoot — boot gate over loader status, fail-loud plugin failure list fix(gui): AppRoot gates on explicit settled signal — status-derived readiness races the incrementally filled table feat(client/ui-theme): ThemeService real implementation — registry with built-in light/dark, apply toggles body[data-ds-dark-theme], third-party token overrides as body inline vars feat(web-react): scopedSlots outlet (kind matrix, inject WeakMap caches, per-entry error boundary) + spec feat(gui): web shell module-table seed — pure-library entities for the loader require surface feat(client/i18n): I18nService real implementation — ns×locale registry, stable bind(ns) reference, zh fallback chain, zh/en skeleton dictionaries feat(gui): web shell assembly closure — layout exports via module table, SessionProvider + scopedSlots + RootBindingProvider feat: client/ui-conversation feat: code codedoc build(gui): root bundle green — web shell excluded from the lib workspace (vite app), ui-primitives lib externalizes css side-effect imports (web-ui precedent) gates(gui): verify-cordis-config follows aggregate tsconfig references (root is a shell over host/client programs); module graph regenerated for the twelve client packages chore(gui): retire legacy migration sources — every owner rewrite landed (t0-checklist §7 ledger honored); orphan css of retired components removed gates(gui): knip green groundwork — e2e/tsx entries for the new packages, loader-runtime deps ignored where loading is by specifier string, fake plugin ids un-bare-named, dead test export dropped chore(client): manifest shape batch A — ui-slots/web-react/ui-primitives invariant companions, files whitelist, cordis+invariants peer/dev, tsconfig refs chore(client): manifest shape batch B — connection/runtime/ui-conversation/ui-trajectory files whitelist, cordis peer+dev, explicit invariant lib entries (clientBundle signature) chore(client): manifest shape batch C — i18n/ui-layout/ui-sidebar/ui-theme invariant companions, files whitelist, invariants peer/dev, tsconfig refs chore(client): manifest shape batch D — web shell gains node-half lib entry + invariant companion + uniform files whitelist chore(client): drop verified-unused deps — dsh-tools from runtime/ui-conversation (types ride /presentation), ui-primitives+clsx from ui-layout gates(gui): doc-gate fixes — theme JSDoc prose, three client type-link exemptions, agent-note paths follow the migration, config catalog regenerated gates(gui): type-equiv manifest follows the types.ts extraction, approval JSDoc keeps its link form, persistence catalog regenerated docs(gui): per-constant JSDoc on the contract geometry exports (export-jsdoc gate) test(gates): loader-composition budget covers cold tsx resolution after the program split (was flaking at the default 5s) docs(gui): README substantiation batch 1 — ui-slots/ui-primitives/web-react/connection: Model Experience short form, real deferred-work ledgers, description accuracy pass fix(client): theme/i18n dual-entry split — service classes + cordis merges move to src/client (host catalog scanner no longer misclassifies client services), node halves keep types + empty apply; catalogs regenerated docs(gui): README substantiation batch 2 — runtime/ui-layout/ui-sidebar/ui-conversation: Model Experience short form, package-owned deferred-work ledgers (unload stub, watch approximation, /client value-import rule, global details state, two-state dots, stats duration gap, single-bundle caches) docs(gui): README substantiation batch 3 — ui-trajectory/ui-theme/i18n/web: Model Experience short form, deferred-work ledgers (placeholder charter, no theme toggle owner, empty locale dictionaries, one-shot rendering); both README gates green test(scripts): purity spec adopts clientBundle two-arg signature (explicit libEntry, no default) gates(gui): knip green — declaration-merge dep ignored, fake plugin id assembled at runtime, invariants dep de-duplicated to peer+dev, stale apps/web section dropped feat(gui): 门禁波次 host 三包 invariant 形状——apiproxy explained-empty 伴生(wire 契约层零事件面)、webserver 真关系伴生(manifest 行必解析出 clientPath,防 __DSH_BOOT__ 广告 404 bundle;apps/cli 发布 webPlugins 键供审计)、runtime 补 files 白名单;三包 exports/files/peer+dev/tsconfig refs 齐 fw-react 形状;constraints+invariants 双 gate 零违规 build(client): ui-layout/ui-sidebar tsdown configs adopt the explicit two-arg clientBundle signature (orphaned follow-up of the manifest shape batch) refactor(gui): shell boot becomes a library face — bootWebShell(el) exported for the apps/web entry; main.ts retired refactor(gui): exports 纪律刀1——ui-theme/i18n node index 收敛为只空 apply(Translate/LocaleDict/ThemeTokens 类型下沉 src/client/),ui-conversation 的 I18nService import 改 /client 子路径 build(typecheck): converge to root host aggregate + tsconfig.client.json — delete tsconfig.host.json, verify-cordis-config seeds both aggregates feat(gui): apps/web restored as the vite application — thin main over bootWebShell; dsh-client-web becomes a plain lib (index exports shell surface, vite files and e2e moved out) chore(gates): knip.json rewritten on the master base — same semantics, minimal diff (formatting churn dropped) docs(gui): 时效清扫②——testing.md 删 web-ui 覆盖豁免残句;web-styling.md 加 token 换代头注(--dsw-* 现行、工程约束条款仍有效并注明收编处) docs(gui): 时效清扫③——四对 GUI Agent Note 加路径更新头注(web-runtime/web-ui/dsh-frontend→现行 12 包结构;设计结论存续声明;双语对同步) docs(gui): 时效清扫③b——四对 note 头注的 i18n 配对哈希重录 build(typecheck): minimal-diff tsconfig shape — drop root files entry (purity spec + preset move to client program), compress comments, drop redundant util/home root ref feat(gui): apps/web restoration follow-through — dsh-frontend package name, cli dist resolve, root build:web filter, tsdown exemption dropped, vitest web lane + knip + client aggregate retargeted, e2e paths rebased refactor(gui): exports 纪律刀2——connection wire 六件 git mv 进 src/client/(wire 即该 dshClient 插件的 client 半),node index=只空 apply,/client 半边整面导出(v3 §3.2 清单原样),包内 tests 改 src/client 直取 refactor(gui): exports 纪律刀3——runtime 实现整体下沉 src/client/(sessions/slots/loader;契约类型与 cordis merge 随迁 client/index),node index=只空 apply;./loader exports 指 client/loader;全消费面(web 壳/ui-sidebar/ui-trajectory/tests)bare→/client 机械跟改;vitest.e2e 换 tsconfig.vitest paths(root tsconfig 排除 client 会把 /client import 掉到 exports 的浏览器 dist bundle) refactor(gui): exports 纪律刀3 补遗——ui-layout 三处 bare runtime import 改 /client(刀3 消费面机械跟改漏提交件;跨属地机械一行×3 报备 ui-shell) test(gui): drop the getSessionManager singleton case — the init/get pair is a dead legacy-boot surface with zero live consumers (SessionsService constructs and holds the manager under the plugin architecture); source removal tracked with rt-core refactor(gui): 删 manager.ts 尾部 initSessionManager/getSessionManager 单例对——旧 boot 直连遗物,插件化下 SessionsService 构造持有 manager,全仓零活消费者(convo-b 测试清扫对表,其测试用例已先行退役 7e2c51898);头注释同步去单例措辞 code refactor --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 6 + ...026-07-19-gui-layering-and-rpc-protocol.md | 253 +++ ...-07-19-gui-layering-and-rpc-protocol.zh.md | 251 +++ ...7-19-gui-web-client-architecture.i18n.yaml | 6 + .../2026-07-19-gui-web-client-architecture.md | 148 ++ ...26-07-19-gui-web-client-architecture.zh.md | 148 ++ ...2-slot-type-chain-implementation.i18n.yaml | 6 + ...26-07-22-slot-type-chain-implementation.md | 47 + ...07-22-slot-type-chain-implementation.zh.md | 47 + .../2026-07-19-web-styling-system.i18n.yaml | 6 + .../process/2026-07-19-web-styling-system.md | 61 + .../2026-07-19-web-styling-system.zh.md | 61 + .../2026-07-20-gui-testing-system.i18n.yaml | 6 + .../process/2026-07-20-gui-testing-system.md | 59 + .../2026-07-20-gui-testing-system.zh.md | 59 + .gitignore | 2 + apps/cli/package.json | 23 + apps/cli/src/bin.ts | 22 + apps/cli/src/headless.ts | 104 ++ apps/cli/src/web.ts | 89 + apps/cli/tsconfig.json | 18 + apps/web/index.html | 12 + apps/web/package.json | 37 + apps/web/src/main.ts | 10 + apps/web/tests/smoke-fixture.e2e.ts | 147 ++ apps/web/tests/smoke-real.e2e.ts | 235 +++ apps/web/tests/support.ts | 47 + apps/web/tsconfig.json | 22 + apps/web/vite.config.ts | 26 + docs/config-catalog.md | 17 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 4 +- docs/event-producer-consumer.md | 16 +- docs/module-graph.md | 49 + docs/persistence-catalog.md | 6 +- docs/web-styling.md | 107 ++ eslint.config.mjs | 16 + knip.json | 536 +++++- package.json | 11 +- packages/client/AGENTS.md | 71 + packages/client/connection/README.md | 16 + packages/client/connection/package.json | 53 + packages/client/connection/src/client/api.ts | 47 + .../connection/src/client/connection.ts | 190 +++ .../client/connection/src/client/fixture.ts | 554 +++++++ .../client/connection/src/client/index.ts | 76 + .../connection/src/client/web-api-client.ts | 12 + packages/client/connection/src/index.ts | 10 + packages/client/connection/src/invariant.ts | 32 + .../connection/tests/api-helpers.spec.ts | 21 + .../connection/tests/connection.spec.ts | 238 +++ packages/client/connection/tests/fake-api.ts | 154 ++ .../client/connection/tests/fixture.spec.ts | 338 ++++ .../client/connection/tests/node-half.spec.ts | 10 + packages/client/connection/tsconfig.json | 42 + packages/client/connection/tsdown.config.ts | 3 + packages/client/i18n/README.md | 16 + packages/client/i18n/package.json | 54 + packages/client/i18n/src/client/index.ts | 108 ++ packages/client/i18n/src/index.ts | 11 + packages/client/i18n/src/invariant.ts | 32 + packages/client/i18n/src/locales/en.ts | 2 + packages/client/i18n/src/locales/zh.ts | 2 + packages/client/i18n/tests/i18n.spec.ts | 53 + packages/client/i18n/tests/invariant.spec.ts | 30 + packages/client/i18n/tsconfig.json | 27 + packages/client/i18n/tsdown.config.ts | 3 + packages/client/runtime/README.md | 18 + packages/client/runtime/package.json | 63 + packages/client/runtime/src/client/index.ts | 128 ++ .../client/runtime/src/client/loader/index.ts | 247 +++ .../src/client/sessions/conversation.ts | 165 ++ .../src/client/sessions/fold-adapter.ts | 194 +++ .../runtime/src/client/sessions/lineage.ts | 63 + .../runtime/src/client/sessions/manager.ts | 250 +++ .../runtime/src/client/sessions/notifier.ts | 61 + .../runtime/src/client/sessions/partial.ts | 89 + .../runtime/src/client/sessions/service.ts | 221 +++ .../runtime/src/client/sessions/session.ts | 527 ++++++ packages/client/runtime/src/client/slots.ts | 107 ++ packages/client/runtime/src/index.ts | 11 + packages/client/runtime/src/invariant.ts | 52 + .../runtime/tests/client-loader-bundle.e2e.ts | 77 + .../runtime/tests/client-loader.spec.ts | 192 +++ .../client/runtime/tests/conversation.spec.ts | 23 + packages/client/runtime/tests/event-script.ts | 50 + packages/client/runtime/tests/fake-api.ts | 157 ++ .../client/runtime/tests/fold-adapter.spec.ts | 145 ++ packages/client/runtime/tests/lineage.spec.ts | 55 + packages/client/runtime/tests/manager.spec.ts | 223 +++ .../client/runtime/tests/node-half.spec.ts | 10 + .../client/runtime/tests/notifier.spec.ts | 75 + packages/client/runtime/tests/partial.spec.ts | 91 + packages/client/runtime/tests/session.spec.ts | 625 +++++++ .../runtime/tests/sessions-service.spec.ts | 141 ++ .../runtime/tests/slots-service.spec.ts | 65 + packages/client/runtime/tsconfig.json | 39 + packages/client/runtime/tsdown.config.ts | 23 + packages/client/tsdown.client.ts | 152 ++ packages/client/ui-conversation/README.md | 22 + packages/client/ui-conversation/package.json | 63 + .../ui-conversation/src/client/apply.ts | 185 +++ .../client/chat/AssistantMarkdown.module.css | 33 + .../src/client/chat/AssistantMarkdown.tsx | 56 + .../src/client/chat/ChatView.module.css | 101 ++ .../src/client/chat/ChatView.tsx | 293 ++++ .../src/client/chat/GenericToolCard.tsx | 36 + .../src/client/chat/IconSparkle16.tsx | 15 + .../src/client/chat/MessageItem.module.css | 34 + .../src/client/chat/MessageItem.tsx | 56 + .../src/client/chat/PendingCard.module.css | 31 + .../src/client/chat/PendingCard.tsx | 31 + .../src/client/chat/StatsLine.module.css | 13 + .../src/client/chat/StatsLine.tsx | 68 + .../src/client/chat/ToolRow.module.css | 88 + .../src/client/chat/ToolRow.tsx | 75 + .../src/client/chat/ToolViewOutlet.tsx | 89 + .../src/client/chat/chat-flow.ts | 46 + .../src/client/chat/register.ts | 52 + .../src/client/contract/slots.ts | 63 + .../src/client/contract/tool-call-model.ts | 126 ++ .../src/client/contract/toolview.ts | 77 + .../src/client/contract/views.ts | 68 + .../ui-conversation/src/client/index.ts | 42 + .../ui-conversation/src/client/service.ts | 251 +++ .../skeleton/ConversationRoot.module.css | 129 ++ .../src/client/skeleton/ConversationRoot.tsx | 109 ++ .../client/skeleton/DetailsPanel.module.css | 94 ++ .../src/client/skeleton/DetailsPanel.tsx | 111 ++ .../src/client/skeleton/EmptyState.module.css | 68 + .../src/client/skeleton/EmptyState.tsx | 106 ++ .../src/client/skeleton/InputBar.module.css | 160 ++ .../src/client/skeleton/InputBar.tsx | 142 ++ .../client/toolviews/bash-sample.module.css | 48 + .../src/client/toolviews/bash-sample.tsx | 52 + .../src/client/toolviews/registry.ts | 102 ++ .../ui-conversation/src/css-modules.d.ts | 6 + packages/client/ui-conversation/src/index.ts | 10 + .../client/ui-conversation/src/invariant.ts | 33 + .../tests/apply-inject.spec.tsx | 276 ++++ .../ui-conversation/tests/chat-apply.spec.tsx | 105 ++ .../tests/chat-branch-tails.spec.tsx | 156 ++ .../tests/chat-stats-bash-sample.spec.tsx | 178 ++ .../tests/chat-tool-row.spec.tsx | 147 ++ .../ui-conversation/tests/chat-view.spec.tsx | 305 ++++ .../tests/coverage-tails.spec.tsx | 127 ++ .../tests/gate-branch-tails.spec.tsx | 140 ++ .../ui-conversation/tests/input-bar.spec.tsx | 131 ++ .../tests/selection-survival.spec.ts | 121 ++ .../tests/service-orchestration.spec.ts | 190 +++ .../tests/service-stores.spec.ts | 176 ++ .../tests/skeleton-branches.spec.tsx | 245 +++ .../ui-conversation/tests/skeleton.spec.tsx | 181 ++ .../tests/toolview-entry-types.spec.ts | 62 + .../tests/toolview-registry.spec.ts | 101 ++ .../tests/toolviews-type-chain.spec.ts | 96 ++ .../tests/views-type-chain.spec.tsx | 100 ++ packages/client/ui-conversation/tsconfig.json | 46 + .../client/ui-conversation/tsdown.config.ts | 3 + packages/client/ui-layout/README.md | 19 + packages/client/ui-layout/package.json | 59 + .../ui-layout/src/client/AppFrame.module.css | 73 + .../client/ui-layout/src/client/AppFrame.tsx | 149 ++ .../client/ui-layout/src/client/columns.ts | 79 + packages/client/ui-layout/src/client/index.ts | 81 + .../client/ui-layout/src/client/service.ts | 132 ++ .../client/ui-layout/src/css-modules.d.ts | 6 + packages/client/ui-layout/src/index.ts | 10 + packages/client/ui-layout/src/invariant.ts | 31 + .../client/ui-layout/tests/app-frame.spec.tsx | 208 +++ packages/client/ui-layout/tests/apply.spec.ts | 71 + .../client/ui-layout/tests/columns.spec.ts | 100 ++ .../client/ui-layout/tests/service.spec.ts | 138 ++ packages/client/ui-layout/tsconfig.json | 37 + packages/client/ui-layout/tsdown.config.ts | 3 + packages/client/ui-primitives/README.md | 18 + packages/client/ui-primitives/package.json | 42 + .../ui-primitives/src/Button.module.css | 73 + packages/client/ui-primitives/src/Button.tsx | 31 + .../src/ConnectionBanner.module.css | 13 + .../ui-primitives/src/ConnectionBanner.tsx | 16 + .../client/ui-primitives/src/FishLogo.tsx | 27 + .../client/ui-primitives/src/Input.module.css | 38 + packages/client/ui-primitives/src/Input.tsx | 23 + .../client/ui-primitives/src/Menu.module.css | 68 + packages/client/ui-primitives/src/Menu.tsx | 81 + .../client/ui-primitives/src/Pill.module.css | 27 + packages/client/ui-primitives/src/Pill.tsx | 31 + .../ui-primitives/src/StateDot.module.css | 65 + .../client/ui-primitives/src/StateDot.tsx | 55 + .../client/ui-primitives/src/css-modules.d.ts | 6 + .../client/ui-primitives/src/icons/index.tsx | 575 +++++++ .../client/ui-primitives/src/icons/props.ts | 8 + packages/client/ui-primitives/src/index.ts | 19 + .../client/ui-primitives/src/invariant.ts | 31 + .../src/markdown/JsonBlock.module.css | 32 + .../ui-primitives/src/markdown/JsonBlock.tsx | 32 + .../src/markdown/MessageText.module.css | 9 + .../src/markdown/MessageText.tsx | 7 + .../client/ui-primitives/tests/atoms.spec.tsx | 122 ++ .../client/ui-primitives/tests/icons.spec.tsx | 58 + .../ui-primitives/tests/invariant.spec.ts | 12 + .../ui-primitives/tests/markdown.spec.tsx | 49 + .../ui-primitives/tests/state-dot.spec.tsx | 45 + packages/client/ui-primitives/tsconfig.json | 25 + .../client/ui-primitives/tsdown.config.ts | 31 + packages/client/ui-sidebar/README.md | 19 + packages/client/ui-sidebar/package.json | 62 + .../ui-sidebar/src/client/Rows.module.css | 158 ++ .../client/ui-sidebar/src/client/Rows.tsx | 122 ++ .../src/client/SidebarRoot.module.css | 248 +++ .../ui-sidebar/src/client/SidebarRoot.tsx | 164 ++ .../ui-sidebar/src/client/contract/slots.ts | 49 + .../client/ui-sidebar/src/client/index.ts | 71 + .../client/ui-sidebar/src/client/store.ts | 94 ++ packages/client/ui-sidebar/src/client/tree.ts | 265 +++ .../client/ui-sidebar/src/css-modules.d.ts | 6 + packages/client/ui-sidebar/src/index.ts | 10 + packages/client/ui-sidebar/src/invariant.ts | 32 + .../client/ui-sidebar/tests/apply.spec.tsx | 158 ++ .../client/ui-sidebar/tests/invariant.spec.ts | 18 + .../ui-sidebar/tests/sidebar-root.spec.tsx | 195 +++ .../client/ui-sidebar/tests/store.spec.ts | 111 ++ packages/client/ui-sidebar/tests/tree.spec.ts | 234 +++ packages/client/ui-sidebar/tsconfig.json | 40 + packages/client/ui-sidebar/tsdown.config.ts | 3 + packages/client/ui-slots/README.md | 18 + packages/client/ui-slots/package.json | 38 + packages/client/ui-slots/src/index.ts | 407 +++++ packages/client/ui-slots/src/invariant.ts | 32 + packages/client/ui-slots/tests/core.spec.ts | 209 +++ .../client/ui-slots/tests/invariant.spec.ts | 12 + .../client/ui-slots/tests/surface.spec.ts | 52 + .../client/ui-slots/tests/type-chain.spec.tsx | 140 ++ packages/client/ui-slots/tsconfig.json | 21 + packages/client/ui-theme/README.md | 17 + packages/client/ui-theme/package.json | 52 + packages/client/ui-theme/src/client/index.ts | 85 + packages/client/ui-theme/src/index.ts | 11 + packages/client/ui-theme/src/invariant.ts | 31 + packages/client/ui-theme/src/styles/base.css | 10 + .../ui-theme/src/styles/design-platform.css | 326 ++++ .../src/styles/gradient-shadow-text.css | 224 +++ .../client/ui-theme/tests/invariant.spec.ts | 27 + packages/client/ui-theme/tests/theme.spec.ts | 61 + packages/client/ui-theme/tsconfig.json | 24 + packages/client/ui-theme/tsdown.config.ts | 3 + packages/client/ui-trajectory/README.md | 15 + packages/client/ui-trajectory/package.json | 59 + .../client/TrajectoryStatsHeader.module.css | 7 + .../src/client/TrajectoryStatsHeader.tsx | 28 + .../src/client/TrajectoryView.tsx | 28 + .../src/client/WaterfallView.tsx | 49 + .../client/ui-trajectory/src/client/index.ts | 46 + .../client/ui-trajectory/src/client/spans.ts | 71 + .../ui-trajectory/src/client/views.module.css | 37 + .../client/ui-trajectory/src/css-modules.d.ts | 6 + packages/client/ui-trajectory/src/index.ts | 10 + .../client/ui-trajectory/src/invariant.ts | 32 + .../ui-trajectory/tests/client-bundle.spec.ts | 81 + .../client/ui-trajectory/tests/views.spec.tsx | 197 +++ packages/client/ui-trajectory/tsconfig.json | 34 + .../client/ui-trajectory/tsdown.config.ts | 3 + packages/client/web-react/README.md | 17 + packages/client/web-react/package.json | 50 + packages/client/web-react/src/bind.ts | 22 + packages/client/web-react/src/env.d.ts | 5 + packages/client/web-react/src/index.ts | 41 + packages/client/web-react/src/invariant.ts | 32 + .../client/web-react/src/scoped-slots.tsx | 191 +++ .../client/web-react/src/session-provider.tsx | 74 + packages/client/web-react/src/store/index.ts | 150 ++ packages/client/web-react/src/use-invoke.ts | 62 + .../src/use-sync-external-store.d.ts | 14 + packages/client/web-react/tests/bind.spec.tsx | 125 ++ .../tests/scoped-slots-real-core.spec.tsx | 70 + .../web-react/tests/scoped-slots.spec.tsx | 274 +++ .../web-react/tests/session-provider.spec.tsx | 106 ++ packages/client/web-react/tests/store.spec.ts | 135 ++ .../web-react/tests/use-invoke.spec.tsx | 84 + packages/client/web-react/tsconfig.json | 25 + packages/client/web-react/tsdown.config.ts | 42 + packages/client/web/README.md | 19 + packages/client/web/package.json | 51 + packages/client/web/src/AppRoot.module.css | 66 + packages/client/web/src/AppRoot.tsx | 52 + packages/client/web/src/app.tsx | 89 + packages/client/web/src/base.css | 19 + packages/client/web/src/boot.tsx | 66 + packages/client/web/src/css-modules.d.ts | 6 + packages/client/web/src/index.ts | 11 + packages/client/web/src/invariant.ts | 32 + packages/client/web/src/seed.ts | 35 + packages/client/web/tests/app-root.spec.tsx | 73 + packages/client/web/tests/boot.spec.tsx | 200 +++ packages/client/web/tsconfig.json | 49 + packages/client/web/tsdown.config.ts | 31 + packages/core/session/package.json | 11 +- packages/core/session/src/surface.ts | 24 +- packages/core/session/tests/surface.spec.ts | 32 + packages/core/tools/package.json | 5 + packages/host/apiproxy/README.md | 27 + packages/host/apiproxy/package.json | 59 + .../host/apiproxy/src/api/approvals.schema.ts | 21 + packages/host/apiproxy/src/api/approvals.ts | 21 + .../host/apiproxy/src/api/events.schema.ts | 42 + packages/host/apiproxy/src/api/events.ts | 69 + packages/host/apiproxy/src/api/host.schema.ts | 19 + packages/host/apiproxy/src/api/host.ts | 25 + packages/host/apiproxy/src/api/index.ts | 46 + .../host/apiproxy/src/api/questions.schema.ts | 26 + packages/host/apiproxy/src/api/questions.ts | 19 + packages/host/apiproxy/src/api/rpc-map.ts | 26 + packages/host/apiproxy/src/api/rpc.schema.ts | 97 ++ packages/host/apiproxy/src/api/rpc.ts | 113 ++ .../host/apiproxy/src/api/sessions.schema.ts | 110 ++ packages/host/apiproxy/src/api/sessions.ts | 73 + packages/host/apiproxy/src/fetch/client.ts | 302 ++++ packages/host/apiproxy/src/fetch/handler.ts | 197 +++ packages/host/apiproxy/src/index.ts | 13 + packages/host/apiproxy/src/invariant.ts | 33 + .../apiproxy/tests/client-handler.spec.ts | 407 +++++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 305 ++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 161 ++ packages/host/apiproxy/tsconfig.json | 33 + packages/host/runtime/README.md | 31 + packages/host/runtime/package.json | 82 + packages/host/runtime/src/api-proxy.ts | 429 +++++ packages/host/runtime/src/boot.ts | 133 ++ packages/host/runtime/src/index.ts | 14 + packages/host/runtime/src/invariant.ts | 31 + packages/host/runtime/src/start.ts | 58 + packages/host/runtime/src/web-plugins.ts | 63 + .../host/runtime/tests/api-proxy-cold.spec.ts | 94 ++ .../host/runtime/tests/api-proxy-view.spec.ts | 179 ++ .../host/runtime/tests/host-runtime.spec.ts | 367 +++++ .../host/runtime/tests/web-plugins.e2e.ts | 71 + .../host/runtime/tests/web-plugins.spec.ts | 114 ++ packages/host/runtime/tsconfig.json | 144 ++ packages/host/webserver/README.md | 23 + packages/host/webserver/package.json | 37 + packages/host/webserver/src/index.ts | 208 +++ packages/host/webserver/src/invariant.ts | 49 + packages/host/webserver/src/static.ts | 58 + packages/host/webserver/src/web-plugins.ts | 184 +++ .../host/webserver/tests/invariant.spec.ts | 50 + .../host/webserver/tests/web-plugins.spec.ts | 210 +++ .../host/webserver/tests/webserver.spec.ts | 337 ++++ packages/host/webserver/tsconfig.json | 18 + .../tests/loader-composition.spec.ts | 5 +- packages/llm/llm/package.json | 9 + packages/ui/tui/tests/tui.spec.ts | 8 +- packages/ui/user-approval/package.json | 5 + packages/ui/user-approval/src/index.ts | 24 +- packages/ui/user-approval/src/types.ts | 29 + packages/ui/user-approval/tsdown.config.ts | 30 + packages/ui/user-interaction/package.json | 5 + packages/ui/user-interaction/src/index.ts | 40 +- packages/ui/user-interaction/src/types.ts | 44 + pnpm-lock.yaml | 1463 ++++++++++++++++- pnpm-workspace.yaml | 1 + scripts/check-workspace-constraints.ts | 22 + scripts/client-bundle-purity.spec.ts | 60 + scripts/doc-typecheck-paths.ts | 11 + scripts/gen-cordis-catalog.ts | 3 + scripts/run-gates.ts | 2 + scripts/translation-pairing.manifest.json | 10 +- scripts/type-equiv.manifest.json | 1185 ++++++++++--- scripts/verify-client-domain-graph.ts | 102 ++ scripts/verify-cordis-config.ts | 30 +- .../verify-package-readme-model-experience.ts | 15 + tsconfig.base.json | 36 +- tsconfig.build.json | 15 + tsconfig.client.json | 42 + tsconfig.json | 9 + tsconfig.vitest.json | 15 + vitest.config.ts | 17 +- vitest.e2e.config.ts | 9 +- vitest.web.config.ts | 30 + 379 files changed, 33246 insertions(+), 411 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md create mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md create mode 100644 .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md create mode 100644 .agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-19-web-styling-system.md create mode 100644 .agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md create mode 100644 .agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-20-gui-testing-system.md create mode 100644 .agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md create mode 100644 apps/cli/package.json create mode 100644 apps/cli/src/bin.ts create mode 100644 apps/cli/src/headless.ts create mode 100644 apps/cli/src/web.ts create mode 100644 apps/cli/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/main.ts create mode 100644 apps/web/tests/smoke-fixture.e2e.ts create mode 100644 apps/web/tests/smoke-real.e2e.ts create mode 100644 apps/web/tests/support.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docs/web-styling.md create mode 100644 packages/client/AGENTS.md create mode 100644 packages/client/connection/README.md create mode 100644 packages/client/connection/package.json create mode 100644 packages/client/connection/src/client/api.ts create mode 100644 packages/client/connection/src/client/connection.ts create mode 100644 packages/client/connection/src/client/fixture.ts create mode 100644 packages/client/connection/src/client/index.ts create mode 100644 packages/client/connection/src/client/web-api-client.ts create mode 100644 packages/client/connection/src/index.ts create mode 100644 packages/client/connection/src/invariant.ts create mode 100644 packages/client/connection/tests/api-helpers.spec.ts create mode 100644 packages/client/connection/tests/connection.spec.ts create mode 100644 packages/client/connection/tests/fake-api.ts create mode 100644 packages/client/connection/tests/fixture.spec.ts create mode 100644 packages/client/connection/tests/node-half.spec.ts create mode 100644 packages/client/connection/tsconfig.json create mode 100644 packages/client/connection/tsdown.config.ts create mode 100644 packages/client/i18n/README.md create mode 100644 packages/client/i18n/package.json create mode 100644 packages/client/i18n/src/client/index.ts create mode 100644 packages/client/i18n/src/index.ts create mode 100644 packages/client/i18n/src/invariant.ts create mode 100644 packages/client/i18n/src/locales/en.ts create mode 100644 packages/client/i18n/src/locales/zh.ts create mode 100644 packages/client/i18n/tests/i18n.spec.ts create mode 100644 packages/client/i18n/tests/invariant.spec.ts create mode 100644 packages/client/i18n/tsconfig.json create mode 100644 packages/client/i18n/tsdown.config.ts create mode 100644 packages/client/runtime/README.md create mode 100644 packages/client/runtime/package.json create mode 100644 packages/client/runtime/src/client/index.ts create mode 100644 packages/client/runtime/src/client/loader/index.ts create mode 100644 packages/client/runtime/src/client/sessions/conversation.ts create mode 100644 packages/client/runtime/src/client/sessions/fold-adapter.ts create mode 100644 packages/client/runtime/src/client/sessions/lineage.ts create mode 100644 packages/client/runtime/src/client/sessions/manager.ts create mode 100644 packages/client/runtime/src/client/sessions/notifier.ts create mode 100644 packages/client/runtime/src/client/sessions/partial.ts create mode 100644 packages/client/runtime/src/client/sessions/service.ts create mode 100644 packages/client/runtime/src/client/sessions/session.ts create mode 100644 packages/client/runtime/src/client/slots.ts create mode 100644 packages/client/runtime/src/index.ts create mode 100644 packages/client/runtime/src/invariant.ts create mode 100644 packages/client/runtime/tests/client-loader-bundle.e2e.ts create mode 100644 packages/client/runtime/tests/client-loader.spec.ts create mode 100644 packages/client/runtime/tests/conversation.spec.ts create mode 100644 packages/client/runtime/tests/event-script.ts create mode 100644 packages/client/runtime/tests/fake-api.ts create mode 100644 packages/client/runtime/tests/fold-adapter.spec.ts create mode 100644 packages/client/runtime/tests/lineage.spec.ts create mode 100644 packages/client/runtime/tests/manager.spec.ts create mode 100644 packages/client/runtime/tests/node-half.spec.ts create mode 100644 packages/client/runtime/tests/notifier.spec.ts create mode 100644 packages/client/runtime/tests/partial.spec.ts create mode 100644 packages/client/runtime/tests/session.spec.ts create mode 100644 packages/client/runtime/tests/sessions-service.spec.ts create mode 100644 packages/client/runtime/tests/slots-service.spec.ts create mode 100644 packages/client/runtime/tsconfig.json create mode 100644 packages/client/runtime/tsdown.config.ts create mode 100644 packages/client/tsdown.client.ts create mode 100644 packages/client/ui-conversation/README.md create mode 100644 packages/client/ui-conversation/package.json create mode 100644 packages/client/ui-conversation/src/client/apply.ts create mode 100644 packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/ChatView.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/ChatView.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/IconSparkle16.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/MessageItem.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/MessageItem.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/PendingCard.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/PendingCard.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/StatsLine.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/StatsLine.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/ToolRow.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/ToolRow.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/chat-flow.ts create mode 100644 packages/client/ui-conversation/src/client/chat/register.ts create mode 100644 packages/client/ui-conversation/src/client/contract/slots.ts create mode 100644 packages/client/ui-conversation/src/client/contract/tool-call-model.ts create mode 100644 packages/client/ui-conversation/src/client/contract/toolview.ts create mode 100644 packages/client/ui-conversation/src/client/contract/views.ts create mode 100644 packages/client/ui-conversation/src/client/index.ts create mode 100644 packages/client/ui-conversation/src/client/service.ts create mode 100644 packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/InputBar.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/InputBar.tsx create mode 100644 packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css create mode 100644 packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx create mode 100644 packages/client/ui-conversation/src/client/toolviews/registry.ts create mode 100644 packages/client/ui-conversation/src/css-modules.d.ts create mode 100644 packages/client/ui-conversation/src/index.ts create mode 100644 packages/client/ui-conversation/src/invariant.ts create mode 100644 packages/client/ui-conversation/tests/apply-inject.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-apply.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-tool-row.spec.tsx create mode 100644 packages/client/ui-conversation/tests/chat-view.spec.tsx create mode 100644 packages/client/ui-conversation/tests/coverage-tails.spec.tsx create mode 100644 packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx create mode 100644 packages/client/ui-conversation/tests/input-bar.spec.tsx create mode 100644 packages/client/ui-conversation/tests/selection-survival.spec.ts create mode 100644 packages/client/ui-conversation/tests/service-orchestration.spec.ts create mode 100644 packages/client/ui-conversation/tests/service-stores.spec.ts create mode 100644 packages/client/ui-conversation/tests/skeleton-branches.spec.tsx create mode 100644 packages/client/ui-conversation/tests/skeleton.spec.tsx create mode 100644 packages/client/ui-conversation/tests/toolview-entry-types.spec.ts create mode 100644 packages/client/ui-conversation/tests/toolview-registry.spec.ts create mode 100644 packages/client/ui-conversation/tests/toolviews-type-chain.spec.ts create mode 100644 packages/client/ui-conversation/tests/views-type-chain.spec.tsx create mode 100644 packages/client/ui-conversation/tsconfig.json create mode 100644 packages/client/ui-conversation/tsdown.config.ts create mode 100644 packages/client/ui-layout/README.md create mode 100644 packages/client/ui-layout/package.json create mode 100644 packages/client/ui-layout/src/client/AppFrame.module.css create mode 100644 packages/client/ui-layout/src/client/AppFrame.tsx create mode 100644 packages/client/ui-layout/src/client/columns.ts create mode 100644 packages/client/ui-layout/src/client/index.ts create mode 100644 packages/client/ui-layout/src/client/service.ts create mode 100644 packages/client/ui-layout/src/css-modules.d.ts create mode 100644 packages/client/ui-layout/src/index.ts create mode 100644 packages/client/ui-layout/src/invariant.ts create mode 100644 packages/client/ui-layout/tests/app-frame.spec.tsx create mode 100644 packages/client/ui-layout/tests/apply.spec.ts create mode 100644 packages/client/ui-layout/tests/columns.spec.ts create mode 100644 packages/client/ui-layout/tests/service.spec.ts create mode 100644 packages/client/ui-layout/tsconfig.json create mode 100644 packages/client/ui-layout/tsdown.config.ts create mode 100644 packages/client/ui-primitives/README.md create mode 100644 packages/client/ui-primitives/package.json create mode 100644 packages/client/ui-primitives/src/Button.module.css create mode 100644 packages/client/ui-primitives/src/Button.tsx create mode 100644 packages/client/ui-primitives/src/ConnectionBanner.module.css create mode 100644 packages/client/ui-primitives/src/ConnectionBanner.tsx create mode 100644 packages/client/ui-primitives/src/FishLogo.tsx create mode 100644 packages/client/ui-primitives/src/Input.module.css create mode 100644 packages/client/ui-primitives/src/Input.tsx create mode 100644 packages/client/ui-primitives/src/Menu.module.css create mode 100644 packages/client/ui-primitives/src/Menu.tsx create mode 100644 packages/client/ui-primitives/src/Pill.module.css create mode 100644 packages/client/ui-primitives/src/Pill.tsx create mode 100644 packages/client/ui-primitives/src/StateDot.module.css create mode 100644 packages/client/ui-primitives/src/StateDot.tsx create mode 100644 packages/client/ui-primitives/src/css-modules.d.ts create mode 100644 packages/client/ui-primitives/src/icons/index.tsx create mode 100644 packages/client/ui-primitives/src/icons/props.ts create mode 100644 packages/client/ui-primitives/src/index.ts create mode 100644 packages/client/ui-primitives/src/invariant.ts create mode 100644 packages/client/ui-primitives/src/markdown/JsonBlock.module.css create mode 100644 packages/client/ui-primitives/src/markdown/JsonBlock.tsx create mode 100644 packages/client/ui-primitives/src/markdown/MessageText.module.css create mode 100644 packages/client/ui-primitives/src/markdown/MessageText.tsx create mode 100644 packages/client/ui-primitives/tests/atoms.spec.tsx create mode 100644 packages/client/ui-primitives/tests/icons.spec.tsx create mode 100644 packages/client/ui-primitives/tests/invariant.spec.ts create mode 100644 packages/client/ui-primitives/tests/markdown.spec.tsx create mode 100644 packages/client/ui-primitives/tests/state-dot.spec.tsx create mode 100644 packages/client/ui-primitives/tsconfig.json create mode 100644 packages/client/ui-primitives/tsdown.config.ts create mode 100644 packages/client/ui-sidebar/README.md create mode 100644 packages/client/ui-sidebar/package.json create mode 100644 packages/client/ui-sidebar/src/client/Rows.module.css create mode 100644 packages/client/ui-sidebar/src/client/Rows.tsx create mode 100644 packages/client/ui-sidebar/src/client/SidebarRoot.module.css create mode 100644 packages/client/ui-sidebar/src/client/SidebarRoot.tsx create mode 100644 packages/client/ui-sidebar/src/client/contract/slots.ts create mode 100644 packages/client/ui-sidebar/src/client/index.ts create mode 100644 packages/client/ui-sidebar/src/client/store.ts create mode 100644 packages/client/ui-sidebar/src/client/tree.ts create mode 100644 packages/client/ui-sidebar/src/css-modules.d.ts create mode 100644 packages/client/ui-sidebar/src/index.ts create mode 100644 packages/client/ui-sidebar/src/invariant.ts create mode 100644 packages/client/ui-sidebar/tests/apply.spec.tsx create mode 100644 packages/client/ui-sidebar/tests/invariant.spec.ts create mode 100644 packages/client/ui-sidebar/tests/sidebar-root.spec.tsx create mode 100644 packages/client/ui-sidebar/tests/store.spec.ts create mode 100644 packages/client/ui-sidebar/tests/tree.spec.ts create mode 100644 packages/client/ui-sidebar/tsconfig.json create mode 100644 packages/client/ui-sidebar/tsdown.config.ts create mode 100644 packages/client/ui-slots/README.md create mode 100644 packages/client/ui-slots/package.json create mode 100644 packages/client/ui-slots/src/index.ts create mode 100644 packages/client/ui-slots/src/invariant.ts create mode 100644 packages/client/ui-slots/tests/core.spec.ts create mode 100644 packages/client/ui-slots/tests/invariant.spec.ts create mode 100644 packages/client/ui-slots/tests/surface.spec.ts create mode 100644 packages/client/ui-slots/tests/type-chain.spec.tsx create mode 100644 packages/client/ui-slots/tsconfig.json create mode 100644 packages/client/ui-theme/README.md create mode 100644 packages/client/ui-theme/package.json create mode 100644 packages/client/ui-theme/src/client/index.ts create mode 100644 packages/client/ui-theme/src/index.ts create mode 100644 packages/client/ui-theme/src/invariant.ts create mode 100644 packages/client/ui-theme/src/styles/base.css create mode 100644 packages/client/ui-theme/src/styles/design-platform.css create mode 100644 packages/client/ui-theme/src/styles/gradient-shadow-text.css create mode 100644 packages/client/ui-theme/tests/invariant.spec.ts create mode 100644 packages/client/ui-theme/tests/theme.spec.ts create mode 100644 packages/client/ui-theme/tsconfig.json create mode 100644 packages/client/ui-theme/tsdown.config.ts create mode 100644 packages/client/ui-trajectory/README.md create mode 100644 packages/client/ui-trajectory/package.json create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryView.tsx create mode 100644 packages/client/ui-trajectory/src/client/WaterfallView.tsx create mode 100644 packages/client/ui-trajectory/src/client/index.ts create mode 100644 packages/client/ui-trajectory/src/client/spans.ts create mode 100644 packages/client/ui-trajectory/src/client/views.module.css create mode 100644 packages/client/ui-trajectory/src/css-modules.d.ts create mode 100644 packages/client/ui-trajectory/src/index.ts create mode 100644 packages/client/ui-trajectory/src/invariant.ts create mode 100644 packages/client/ui-trajectory/tests/client-bundle.spec.ts create mode 100644 packages/client/ui-trajectory/tests/views.spec.tsx create mode 100644 packages/client/ui-trajectory/tsconfig.json create mode 100644 packages/client/ui-trajectory/tsdown.config.ts create mode 100644 packages/client/web-react/README.md create mode 100644 packages/client/web-react/package.json create mode 100644 packages/client/web-react/src/bind.ts create mode 100644 packages/client/web-react/src/env.d.ts create mode 100644 packages/client/web-react/src/index.ts create mode 100644 packages/client/web-react/src/invariant.ts create mode 100644 packages/client/web-react/src/scoped-slots.tsx create mode 100644 packages/client/web-react/src/session-provider.tsx create mode 100644 packages/client/web-react/src/store/index.ts create mode 100644 packages/client/web-react/src/use-invoke.ts create mode 100644 packages/client/web-react/src/use-sync-external-store.d.ts create mode 100644 packages/client/web-react/tests/bind.spec.tsx create mode 100644 packages/client/web-react/tests/scoped-slots-real-core.spec.tsx create mode 100644 packages/client/web-react/tests/scoped-slots.spec.tsx create mode 100644 packages/client/web-react/tests/session-provider.spec.tsx create mode 100644 packages/client/web-react/tests/store.spec.ts create mode 100644 packages/client/web-react/tests/use-invoke.spec.tsx create mode 100644 packages/client/web-react/tsconfig.json create mode 100644 packages/client/web-react/tsdown.config.ts create mode 100644 packages/client/web/README.md create mode 100644 packages/client/web/package.json create mode 100644 packages/client/web/src/AppRoot.module.css create mode 100644 packages/client/web/src/AppRoot.tsx create mode 100644 packages/client/web/src/app.tsx create mode 100644 packages/client/web/src/base.css create mode 100644 packages/client/web/src/boot.tsx create mode 100644 packages/client/web/src/css-modules.d.ts create mode 100644 packages/client/web/src/index.ts create mode 100644 packages/client/web/src/invariant.ts create mode 100644 packages/client/web/src/seed.ts create mode 100644 packages/client/web/tests/app-root.spec.tsx create mode 100644 packages/client/web/tests/boot.spec.tsx create mode 100644 packages/client/web/tsconfig.json create mode 100644 packages/client/web/tsdown.config.ts create mode 100644 packages/host/apiproxy/README.md create mode 100644 packages/host/apiproxy/package.json create mode 100644 packages/host/apiproxy/src/api/approvals.schema.ts create mode 100644 packages/host/apiproxy/src/api/approvals.ts create mode 100644 packages/host/apiproxy/src/api/events.schema.ts create mode 100644 packages/host/apiproxy/src/api/events.ts create mode 100644 packages/host/apiproxy/src/api/host.schema.ts create mode 100644 packages/host/apiproxy/src/api/host.ts create mode 100644 packages/host/apiproxy/src/api/index.ts create mode 100644 packages/host/apiproxy/src/api/questions.schema.ts create mode 100644 packages/host/apiproxy/src/api/questions.ts create mode 100644 packages/host/apiproxy/src/api/rpc-map.ts create mode 100644 packages/host/apiproxy/src/api/rpc.schema.ts create mode 100644 packages/host/apiproxy/src/api/rpc.ts create mode 100644 packages/host/apiproxy/src/api/sessions.schema.ts create mode 100644 packages/host/apiproxy/src/api/sessions.ts create mode 100644 packages/host/apiproxy/src/fetch/client.ts create mode 100644 packages/host/apiproxy/src/fetch/handler.ts create mode 100644 packages/host/apiproxy/src/index.ts create mode 100644 packages/host/apiproxy/src/invariant.ts create mode 100644 packages/host/apiproxy/tests/client-handler.spec.ts create mode 100644 packages/host/apiproxy/tests/fetch-carrier.spec.ts create mode 100644 packages/host/apiproxy/tests/rpc-schemas.spec.ts create mode 100644 packages/host/apiproxy/tsconfig.json create mode 100644 packages/host/runtime/README.md create mode 100644 packages/host/runtime/package.json create mode 100644 packages/host/runtime/src/api-proxy.ts create mode 100644 packages/host/runtime/src/boot.ts create mode 100644 packages/host/runtime/src/index.ts create mode 100644 packages/host/runtime/src/invariant.ts create mode 100644 packages/host/runtime/src/start.ts create mode 100644 packages/host/runtime/src/web-plugins.ts create mode 100644 packages/host/runtime/tests/api-proxy-cold.spec.ts create mode 100644 packages/host/runtime/tests/api-proxy-view.spec.ts create mode 100644 packages/host/runtime/tests/host-runtime.spec.ts create mode 100644 packages/host/runtime/tests/web-plugins.e2e.ts create mode 100644 packages/host/runtime/tests/web-plugins.spec.ts create mode 100644 packages/host/runtime/tsconfig.json create mode 100644 packages/host/webserver/README.md create mode 100644 packages/host/webserver/package.json create mode 100644 packages/host/webserver/src/index.ts create mode 100644 packages/host/webserver/src/invariant.ts create mode 100644 packages/host/webserver/src/static.ts create mode 100644 packages/host/webserver/src/web-plugins.ts create mode 100644 packages/host/webserver/tests/invariant.spec.ts create mode 100644 packages/host/webserver/tests/web-plugins.spec.ts create mode 100644 packages/host/webserver/tests/webserver.spec.ts create mode 100644 packages/host/webserver/tsconfig.json create mode 100644 packages/ui/user-approval/src/types.ts create mode 100644 packages/ui/user-approval/tsdown.config.ts create mode 100644 packages/ui/user-interaction/src/types.ts create mode 100644 scripts/client-bundle-purity.spec.ts create mode 100644 scripts/verify-client-domain-graph.ts create mode 100644 tsconfig.client.json create mode 100644 tsconfig.vitest.json create mode 100644 vitest.web.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml new file mode 100644 index 0000000000..d3e5608c22 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -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 +2026-07-19-gui-layering-and-rpc-protocol.md: ebe21a6060ec69ba9807ab9fbf9906ae24b07823 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 0c256b60ce44a8e16ec6edfba146c776c4ae2129 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md new file mode 100644 index 0000000000..ebe21a6060 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -0,0 +1,253 @@ +# Agent Note: GUI layering and the RPC protocol — host/client layering by capability provider, the four-quadrant message model, and the fetch carrier + +Status: implemented + +English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) + +> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation (HTTP+SSE) is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md). + +## Problem + +We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities: + +- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation) +- Launching inside Electron with the same Web technology shape as `dsh web` + +That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. + +At the same time the physical channels differ per consumer (HTTP/SSE, in-process direct calls, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. + +## Decision + +### Layering + +Directories layer as follows: + +- `packages/host/*`: packages provide host-side capability only (representing the Node.js engineering core built on the existing harness plugin system), and additionally + - the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below +- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Two kinds live here: + - **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`): ordinary root-index packages, statically bundled into the shell and seeded into the browser plugin loader's module table. + - **dshClient plugin packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the entire implementation and its types live under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle), and cross-package consumption imports the `/client` form. `runtime` additionally exports `./loader` (the shell-held browser bundle loader — a loader cannot load itself). +- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. + - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP. + - A future Electron shape reuses the same web client packages over an IPC fetch carrier. + +``` +apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch) + │ consume + ▼ +packages/host/* packages/client/* + apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives + runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + webserver web-shape HTTP carriage client half = src/client/) + │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths + ▼ │ (type-only + the client base class) +harness core packages ──────────────────┘ (types reach the browser via import type) +``` + +Direction discipline (every rule auditable from package deps): + +- `runtime → apiproxy` is one-way; apiproxy depends only on type definitions. +- Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`). +- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency. +- Cross-package client imports use the `/client` subpath for plugin packages (a bare package name would inline a second runtime instance into a browser bundle; the tsdown purity gate rewrites or rejects it). + +TypeScript checks in **two aggregate programs** (`tsconfig.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs. + +On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect). + +#### Layer roles + +| Layer | Package | Responsibility | Key discipline | +|---|---|---|---| +| Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | +| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | +| Carrier layer | `dsh-host-webserver` | Web-shape HTTP: static serving + `/api/*`→handler forwarding + SSE write-out + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | +| Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | +| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture RFC | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | +| Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app | + +#### Naming rule + +Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the plugin packages' `/client` (and runtime's `/loader`) subpaths so source-level resolution matches the exports map. + +#### How to integrate a new shape (operational checklist) + +1. **Pick a fetch impersonation**: browser same-origin HTTP / in-process `host.handler.fetch` injection / your own transport-aspect subclass (e.g. future Electron IPC, see the "Subclass table" below). +2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. +3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. + +The two existing shapes are the template: `apps/cli/src/web.ts` (startHost + dist location + startWebServer + signal shutdown) and `headless.ts` (startHost + InProcessApiClient isomorphic direct calls, zero HTTP zero ports). ACP-class protocol bridges do not follow this checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch. + +## Message protocol + +The sections from here down are the protocol body carried by the front layer (`dsh-host-apiproxy`). The wire has exactly four message kinds (the four quadrants) — the Web carriage in the right column is only an example; swapping the carrier (in-process/IPC) leaves the quadrants unchanged: + +``` + client 发起 server 发起 + request ① ClientRequest ③ ServerRequest + (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + response ② ServerResponse ④ ClientResponse + (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) +``` + +### Wire full forms: a four-member named discriminated union (`api/rpc.ts`) + +| Type | Discriminant tag | Fields | rpcId ownership | Web carriage | +|---|---|---|---|---| +| `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mints | `POST /api/` body | +| `ServerResponse` | `'server-response'` | `rpcId` `result` | echoes ① | that POST's response body (always HTTP 200) | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | SSE `data:` line | +| `ClientResponse` | `'client-response'` | `rpcId` `result` | echoes ③ | `POST /api/respond` body | + +`RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`, narrowed via `switch (message.type)`. + +**rpcId discipline** (`RpcId` is a branded string with constructor `RpcId()`): + +- Whoever initiates mints; a response always echoes the corresponding request's rpcId and **never mints a new id**. +- server-requests split into two kinds, distinguished statically by `method` (= the frame type), with **no third kind**: answerable frames (`approval/requested`, `question/requested`) carry a stable logical request id (minted once on acceptance, reused verbatim on baseline replay, echoed by the client's answer); pure-push frames (`session/event` etc.) carry an rpcId identifying that one push (freshly minted each time). +- Business code never mints: unary minting funnels into the client base class `callUnary`, frame minting funnels into the host side. + +### Signature narrow forms and carrier completion + +Domain interface signatures perceive only the narrow forms: `RpcRequest

= { rpcId, payload }`, `RpcResponse = { rpcId, result: RpcResult }`. The carrier layer completes narrow forms into full forms (adding the `type` tag and `method`); direction is never inferred from the channel. `RpcResult = { ok: true; value } | { ok: false; error: RpcError }` — methods do not throw business errors. + +### RpcReceipt: the carrier receipt + +The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence surface is the `*/resolved` frames. + +## The type system: signatures are the source of truth + +### RpcMethodMap and derived generics (`api/rpc-map.ts`) + +Method parameter/return structures **live only in the interface method signatures**; the map registers the methods themselves; every other position (handler, client, store, tests) references the derived generics — copying literals or introducing flat named types is banned: + +```ts ignore-check +export interface RpcMethodMap { + 'session.list': SessionsApi['list'] // map key 即 wire 路径段 + // …其余方法同形登记,全集见 api/rpc-map.ts +} +// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束) +export type RequestPayload = Parameters[0]['payload'] +export type ResponseValue = + Awaited> extends RpcResponse ? T : never +``` + +Stream methods (`events.mux`/`events.host`) stay out of the map (not unary); `respond` stays out of the map (it is a client-response, not a method call). + +### The error model (`RpcErrorDetailsMap`) + +One example row of an error code: + +| code | details | when | +|---|---|---| +| `bad-request` | `{ issues: ZodIssue[] }` | wire/payload zod validation failed | + +The full code set is `RpcErrorDetailsMap` in `api/rpc.ts`. `RpcError` is the distributive union expanded from the map: `code` discriminates, `details` narrows automatically after a `switch`; **details is required** — a new code = one map row + one error-schema branch, and omission is a compile error. Transport failures (network down, host not up) are thrown by the carrier as exceptions; the two layers never mix. + +### Bidirectional zod validation and anchoring + +- **Two-level parse**: the full-form schema once (type/rpcId/method structure + the handler checking path==method) → the business payload dispatched by method/frame type for a second parse; rejection = `bad-request`. +- **Anchoring**: schemas uniformly `satisfies z.ZodType>` (`api/rpc.schema.ts`). `Wire` is a deep "| undefined" widening — the repo enables `exactOptionalPropertyTypes` while zod `.optional()` outputs `T | undefined`, so anchoring the original type is unusable across the board; on the JSON wire, absence and undefined are indistinguishable, so the widening loses no validation semantics. Passthrough wide branches (`SessionEvent`/`ContentBlock`/frame unions/`RpcError`) and brand-id schemas use explicit casts with comments. +- Brand casts have one point each: every schema file funnels its id cast into one place (`rpcIdSchema` is the only cast point in rpc.schema.ts). + +## The contract face (ApiProxy) + +The root interface is `ApiProxy = { sessions, host, events, respond }` (`api/index.ts`). A new client-request domain = one new file pair (`.ts` + `.schema.ts`) + one root-interface field + one map row. + +### The unary method table + +One example row (the table structure is the reading key): + +| method key | request payload | return value | semantics | +|---|---|---|---| +| `session.list` | `{ cursor?: string }` (cursor is a reserved seat, unimplemented) | `{ items: SessionSummary[] }` | persisted sessions, updatedAt descending; v1 builds no index | + +The remaining methods (`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`. + +### Frames (server→client, named unions) + +Two SSE streams: the mux stream (`GET /api/events.mux`, all-session aggregate) and the host stream (`GET /api/events.host`, host-level events). One example frame row: + +| frame type | payload | when | +|---|---|---| +| `session/event` | `{ sessionId; event: SessionEvent }` | core passthrough: core events pass verbatim, `assistant/chunk` IS the token stream, no separate delta frame | + +The remaining frame types are not re-copied here; the full unions are `MuxFrame`/`HostFrame` in `api/events.ts`. Three semantic points to know: `session/subscribed` carries lastSeq for history seam-race detection; the `approval/question` requested frames are answerable (stable rpcId) and the resolved frames are the convergence surface; `host/agent-error` is the only outlet for live failures with no turn position. + +**Passthrough discipline**: events/messages/content blocks on the wire ARE the core types (`SessionEvent`/`ContentBlock`) — no second DTO set; types reach the browser through the `import type` dependency chain. `SessionEventMap` is merge-extensible: the client applies its documented default (ignore) to unknown types, and the event schema keeps a "valid envelope + unknown type" branch — the envelope stays strict; this is not field-level passthrough. + +### Session semantics (impl-side commitments) + +- **History = event replay**: one fold (client side); history pagination and live increments share one code path; the server maintains no second materialized-snapshot system. History **page boundaries align to message boundaries** (never cut mid-message; chunks group with their finalized message), and the tail page includes the in-flight partial's chunks. +- **Prompt correlation**: the prompt's rpcId rides MessageSource (`'user-rpc'`) into the `user/message` event; the client uses it to promote the optimistic echo. +- **Reconnect = rebuild**: no resume cursor (`mux`'s `since` signature is a reserved seat, ignored if passed); on disconnect reopen the stream + refetch history; compare `subscribed.lastSeq` with the history tail seq and backfill once if there is a seam. +- **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it). +- **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only. +- **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears. +- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. + +## The client carrier: the AbstractApiClient class family (`fetch/client.ts`) + +**Protocol invariants live in the base class; platform differences are two aspects**: the abstract method `doFetch(url, init)` (transport) + the overridable `onEnvelope` (observation). + +### IApiClient: the caller view + +The same domain tree as `ApiProxy`, but unary methods **take the business payload directly** — the carrier mints the rpcId and wraps the envelope; business code never mints, and code needing this call's rpcId reads it from the returned `RpcResponse` echo. `ApiProxy` is the narrow-form signature contract the impl side implements; `IApiClient` is the payload-direct view clients consume; `AbstractApiClient` bridges the two. Methods derive per key from `RpcMethodMap` — a map row addition updates them mechanically. + +### Protocol paths held by the base class + +| Path | Content | +|---|---| +| `callUnary` | mint → tap → POST full form → `serverResponseSchema` parse → **rpcId echo check** (mismatch throws) → tap → emit narrow form | +| `readSse` | streaming fetch (not EventSource), `\n\n` framing, `data:` concatenation, ServerRequest full-form parse, tap, emit narrow `RpcRequest` | +| `respond` | client-response passthrough (rpcId is an echo — never minted here); response body parsed by `rpcReceiptSchema` | +| unary timeout | `AbortSignal.timeout` (default 30s, constructor-tunable); streams have no timeout (long-lived by nature) | +| `resolveBase` | browser = same-origin origin; no-location environment (Node) = the `http://dsh.internal` fake authority | + +### The instance-level envelope observation aspect + +All four quadrant full forms pass through `onEnvelope`; the base implementation is an **instance-owned microtask-batched buffer** (frame storms must not disturb consumers per frame; module-level state would leak across instances/tests, hence instance-owned). Observers subscribe via `subscribeEnvelopes(listener)` (receiving whole batches as `readonly RpcMessage[]`, returning an unsubscribe function); a listener throw is isolated (observation must never bite the carrier). With no subscribers the buffering costs nothing. No shipped consumer subscribes today — the aspect is the designated seat for wire diagnostics (the retired RPC debug panel was its first consumer, and a future one plugs in without touching the carrier). + +### The subclass table (transport carriage) + +| Subclass | Package | doFetch | Purpose | +|---|---|---|---| +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` (same-origin `/api/*`) | the browser shape; HTTP+SSE carriage details in the web client architecture RFC | +| `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | +| (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | + +## How to extend (operational checklists) + +**Add a unary method (5 steps)**: ① add the method signature to the domain interface (parameters/return inline — this is the single source of truth); ② add one `RpcMethodMap` row; ③ add the request/value schema pair in `.schema.ts` (anchored `Wire>`); ④ add one handler `UNARY_ROUTES` row (the handler's Web carriage is in the web client architecture RFC); ⑤ implement in the impl (echo `request.rpcId`). On the client side, add the passthrough row to the `IApiClient`/`AbstractApiClient` domain method tables. + +**Add a frame type (3 steps)**: ① add a branch to the `MuxFrame`/`HostFrame` union (answerable frames must note the stable-rpcId semantics); ② add a frame-schema branch; ③ the consumers' fold/routing documented-default already covers unknown types — add an explicit branch as needed. + +**Add an error code (2 steps)**: ① add one `RpcErrorDetailsMap` row (details required); ② add one `rpcErrorSchema` discriminatedUnion branch. + +**Plug in a new carrier**: subclass `AbstractApiClient` implementing only `doFetch`; to intercept at the protocol layer (like the fixture), override the `callUnary`/`openMux`/`openHost` virtuals instead. Contract and base class stay unchanged. + +**Promote a reserved seam**: copy the reserved signature into the domain interface → add the map row → add the schema pair → add the UNARY_ROUTES row → implement. + +## Consequences + +Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. The accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. + +## Alternatives considered + +| Rejected | One-line reason | +|---|---| +| Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages | +| A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable | +| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | A second command plane bypasses the contract, losing wire validation/observability/multi-client consistency; ctx keeps exactly two formal uses — front doors and headless event subscription | +| webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer | +| Package names without the group prefix (continuing dsh-) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package | +| Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention | +| A three-envelope model (Request/Response/Frame envelopes, signatures direction-blind) | rpcId correlation is logical-layer; frame and response direction semantics inferred from the channel break the moment the carrier changes | +| Named Request/Response type pairs as the source of truth (map registering type pairs) | Flat named types are a second name for the same fact; signature inference makes adding a method a one-place change | +| REST-style paths | The consumer is our own client with no third-party REST expectations; RPC mapping straight onto the method table is more mechanical | +| A DTO layer (a second wire-only structure set) | Core types reach the browser type-only at zero cost; a DTO is a permanent two-way synchronization tax | +| Cursor resumption (implementing mux since) | Reconnect = rebuild (opencode-style) covers all v1 needs; the signature keeps the seat, implementation waits for a real consumer | +| A createApiClient factory function (the original implementation) | Platform differences (transport/observation) are inheritance aspects, not parameters; the class family lets the fixture substitute at the protocol layer instead of wrapping a fake envelope | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md new file mode 100644 index 0000000000..0c256b60ce --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -0,0 +1,251 @@ +# RFC: GUI 分层与 RPC 协议——host/client 按能力支持方分层、四象限消息模型与 fetch 载体 + +Status: implemented + +[English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 + +> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现(HTTP+SSE)见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。 + +## Problem + +需要提供 UI 对接层,除已有 ACP/stdio基础版本外,还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持: +- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留) +- 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动 + +那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 + +同时各消费端的物理通道不同(HTTP/SSE、进程内直调、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 + +## Decision + +### 分层 + +目录按照如下分层: +- `packages/host/*`: 包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含 + - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 +- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住两类包: + - **纯库**(`ui-slots`、`web-react`、`ui-primitives`):普通根入口包,静态打包进壳,并播种进浏览器插件 loader 的模块表。 + - **dshClient 插件包**(`connection`、`runtime`、`ui-theme`、`i18n`、`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现与类型全部住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle),跨包消费一律 import `/client` 形式。`runtime` 额外导出 `./loader`(壳持有的浏览器 bundle loader——loader 加载不了自己)。 +- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 + - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。 + - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 + +``` +apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch) + │ consume + ▼ +packages/host/* packages/client/* + apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives + runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + webserver web-shape HTTP carriage client half = src/client/) + │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths + ▼ │ (type-only + the client base class) +harness core packages ──────────────────┘ (types reach the browser via import type) +``` + +方向纪律(每条都由包 deps 可核): + +- `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。 +- client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。 +- `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。 +- client 侧跨包 import 插件包一律走 `/client` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundle;tsdown 纯度门禁会改写或拒收)。 + +TypeScript 以**两个聚合 program** 检查(`tsconfig.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用。 + +协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 + +#### 分层角色 + +| 层 | 包 | 职责 | 关键纪律 | +|---|---|---|---| +| 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | +| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | +| 承载层 | `dsh-host-webserver` | Web 形态 HTTP:静态服务 + `/api/*`→handler 转发 + SSE 写出 + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | +| client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | +| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构 RFC | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | +| 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app | + +#### 命名规则 + +`packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且插件包的 `/client`(以及 runtime 的 `/loader`)子路径要单列条目,使源码级解析与 exports map 一致。 + +#### 怎么接入一个新形态(操作清单) + +1. **选 fetch 伪造方式**:浏览器同源 HTTP / 进程内 `host.handler.fetch` 注入 / 自写传输切面子类(如将来 Electron IPC,见下文「子类表」)。 +2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 +3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 + +现有两形态即模板:`apps/cli/src/web.ts`(startHost + dist 定位 + startWebServer + 信号停机)与 `headless.ts`(startHost + InProcessApiClient 同构直调,零 HTTP 零端口)。ACP 类协议桥不走本清单:它把 core 暴露给外部生态,直接 `ctx.plugin(前门插件)` 挂载、不套 fetch。 + +## 消息协议 + +以下各节是前置层(`dsh-host-apiproxy`)承载的协议本体。wire 上只有四种消息(四象限)——右列的 Web 承载只是示例,换载体(进程内/IPC)时四象限不变: + +``` + client 发起 server 发起 + request ① ClientRequest ③ ServerRequest + (POST /api/ body) (SSE 帧:session 事件、审批/问答 requested) + response ② ServerResponse ④ ClientResponse + (该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId) +``` + +### wire 全形:四具名判别 union(`api/rpc.ts`) + +| 类型 | 判别 tag | 字段 | rpcId 归属 | Web 承载 | +|---|---|---|---|---| +| `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mint | `POST /api/` body | +| `ServerResponse` | `'server-response'` | `rpcId` `result` | 回填 ① | 该 POST 的应答体(恒 HTTP 200) | +| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | SSE `data:` 行 | +| `ClientResponse` | `'client-response'` | `rpcId` `result` | 回填 ③ | `POST /api/respond` body | + +`RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`,`switch (message.type)` 窄化。 + +**rpcId 纪律**(`RpcId` 是 branded string,构造函数 `RpcId()`): + +- 谁发起谁 mint;应答一律回填对应 request 的 rpcId,**绝不 mint 新 id**。 +- server-request 分两类,静态按 `method`(=帧 type)区分,**不设第三种 kind**:可应答帧(`approval/requested`、`question/requested`)的 rpcId 是稳定逻辑请求 id(受理时 mint 一次、基线重放原样复用、client 以它回填应答);纯推送帧(`session/event` 等)的 rpcId 标识该次推送(每次新 mint)。 +- 业务代码不 mint:unary 的 mint 收口在客户端基类 `callUnary`,帧的 mint 收口在 host 侧。 + +### 签名窄形与载体补全 + +域接口签名只感知窄形:`RpcRequest

= { rpcId, payload }`、`RpcResponse = { rpcId, result: RpcResult }`。载体层把窄形补全为全形(补 `type` tag 与 `method`),方向不靠通道推断。`RpcResult = { ok: true; value } | { ok: false; error: RpcError }`——方法不 throw 业务错误。 + +### RpcReceipt:载体回执 + +`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛面是 `*/resolved` 帧。 + +## 类型体系:函数签名即事实源 + +### RpcMethodMap 与派生泛型(`api/rpc-map.ts`) + +方法的参数/返回结构**只住在接口方法签名里**;map 登记方法本身;其余一切位置(handler、client、store、测试)引用派生泛型,禁止复写字面量或另起平铺具名类型: + +```ts ignore-check +export interface RpcMethodMap { + 'session.list': SessionsApi['list'] // map key 即 wire 路径段 + // …其余方法同形登记,全集见 api/rpc-map.ts +} +// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束) +export type RequestPayload = Parameters[0]['payload'] +export type ResponseValue = + Awaited> extends RpcResponse ? T : never +``` + +流方法(`events.mux`/`events.host`)不进 map(不是 unary);`respond` 不进 map(是 client-response 不是方法调用)。 + +### 错误模型(`RpcErrorDetailsMap`) + +错误码示例一行: + +| code | details | 何时 | +|---|---|---| +| `bad-request` | `{ issues: ZodIssue[] }` | wire/payload zod 校验失败 | + +码全集见 `api/rpc.ts` 的 `RpcErrorDetailsMap`。`RpcError` 是 map 展开的分布式 union:`code` 判别、`switch` 后 `details` 自动窄化;**details 必填**——新码=map 加一行+错误 schema 加一支,漏填是编译错误。transport 故障(断网、host 没起)由载体抛异常,与业务错误两层不混。 + +### zod 双向校验与锚定 + +- **两级 parse**:全形 schema 一次(type/rpcId/method 结构 + handler 校验 path==method)→ 业务 payload 按 method/帧型分派二次 parse;拒收 = `bad-request`。 +- **锚定**:schema 统一 `satisfies z.ZodType>`(`api/rpc.schema.ts`)。`Wire` 是深度「| undefined」宽化——仓库开 `exactOptionalPropertyTypes` 而 zod `.optional()` 输出 `T | undefined`,直接锚原类型全线不可用;JSON wire 上缺席与 undefined 同形,宽化不损失校验语义。透传宽分支(`SessionEvent`/`ContentBlock`/帧 union/`RpcError`)与 brand id schema 用显式 cast + 注释。 +- brand cast 单点:每个 schema 文件的 id cast 收口一处(`rpcIdSchema` 是 rpc.schema.ts 唯一 cast 点)。 + +## 契约面(ApiProxy) + +根接口 `ApiProxy = { sessions, host, events, respond }`(`api/index.ts`)。新 client-request 域 = 新的一对文件(`<域>.ts` + `<域>.schema.ts`)+ 根接口一个字段 + map 加行。 + +### unary 方法表 + +方法示例一行(表结构即读法): + +| method key | 请求 payload | 返回 value | 语义 | +|---|---|---|---| +| `session.list` | `{ cursor?: string }`(cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 session,updatedAt 倒序;v1 不建索引 | + +其余方法(`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。 + +### 帧(server→client,具名 union) + +两条 SSE 流:mux 流(`GET /api/events.mux`,全 session 聚合)与 host 流(`GET /api/events.host`,host 级事件)。帧示例一行: + +| 帧 type | 载荷 | 何时发 | +|---|---|---| +| `session/event` | `{ sessionId; event: SessionEvent }` | 核心透传:core 事件原样过,`assistant/chunk` 即 token 流,无独立 delta 帧 | + +其余帧型不在此复写,union 全集见 `api/events.ts` 的 `MuxFrame`/`HostFrame`。语义上须知三点:`session/subscribed` 的 lastSeq 供 history 补缝竞态检测;`approval/question` 的 requested 帧可应答(rpcId 稳定)、resolved 帧是收敛面;`host/agent-error` 是无 turn 位置 live 失败的唯一出口。 + +**透传纪律**:wire 上的事件/消息/内容块就是 core 类型(`SessionEvent`/`ContentBlock`),不造第二套 DTO;类型经 `import type` 依赖链直达浏览器。`SessionEventMap` merge-extensible:client 对未知 type documented-default(忽略),事件 schema 留「合法信封+未知类型」分支——信封仍严格,不是字段级 passthrough。 + +### 会话语义(impl 侧承诺) + +- **历史 = 事件重放**:一套 fold(client 侧),历史分页与 live 增量同一条代码路径;server 不做物化快照第二套。history **页边界对齐消息边界**(绝不从消息中间截断;chunk 随定稿消息归组),尾页含进行中 partial 的 chunk。 +- **prompt 关联**:prompt 的 rpcId 经 MessageSource(`'user-rpc'`)透传进 `user/message` 事件,client 以此把乐观回显转正。 +- **重连 = 重建**:不做续传 cursor(`mux` 的 `since` 签名留座、传了忽略);断线重开流 + 重拉 history;`subscribed.lastSeq` 与 history 尾 seq 比对,有缝再补拉一次。 +- **冷 session 隐式 resume**:`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume,并发触发用在途表去重;attach 与否不对客暴露(`running` 已覆盖)。 +- **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。 +- **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。 +- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。 + +## 客户端载体:AbstractApiClient 类体系(`fetch/client.ts`) + +**协议不变量住基类,平台差异是两个切面**:抽象方法 `doFetch(url, init)`(传输)+ 可覆写 `onEnvelope`(观测)。 + +### IApiClient:caller 视图 + +与 `ApiProxy` 同域树,但 unary 方法**收业务 payload 直传**——载体 mint rpcId 并包信封,业务代码永不 mint;需要本次调用 rpcId 的从返回的 `RpcResponse` 回显里读。`ApiProxy` 是 impl 侧实现的窄形签名契约,`IApiClient` 是 client 侧消费的 payload 直传视图,`AbstractApiClient` 桥接两者。方法逐 key 从 `RpcMethodMap` 派生——map 加行即机械更新。 + +### 基类持有的协议路径 + +| 路径 | 内容 | +|---|---| +| `callUnary` | mint → tap → POST 全形 → `serverResponseSchema` parse → **rpcId 回显校验**(不符即 throw)→ tap → 吐窄形 | +| `readSse` | streaming fetch(非 EventSource)、`\n\n` 分帧、`data:` 拼接、ServerRequest 全形 parse、tap、吐窄形 `RpcRequest<帧>` | +| `respond` | client-response 透传(rpcId 是回填,此处不 mint);应答体 `rpcReceiptSchema` parse | +| unary 超时 | `AbortSignal.timeout`(默认 30s,构造参数可调);流不设超时(长连接本性) | +| `resolveBase` | 浏览器=同源 origin;无 location 环境(Node)=`http://dsh.internal` 假 authority | + +### 实例级 envelope 观测切面 + +四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费者;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费者订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费者,将来的诊断消费者接入时不动载体)。 + +### 子类表(传输承载) + +| 子类 | 所在包 | doFetch | 用途 | +|---|---|---|---| +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch`(同源 `/api/*`) | 浏览器形态;HTTP+SSE 承载落地见 Web 客户端架构 RFC | +| `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | +| (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 | + +## 怎么扩展(操作清单) + +**加一个 unary 方法(5 步)**:①域接口加方法签名(参数/返回内联,这是唯一事实源);②`RpcMethodMap` 加一行;③`<域>.schema.ts` 加 request/value schema 对(锚 `Wire>`);④handler `UNARY_ROUTES` 加一行(handler 的 Web 承载见 Web 客户端架构 RFC);⑤impl 实现(回显 `request.rpcId`)。client 侧 `IApiClient`/`AbstractApiClient` 的域方法表同步加一行透传。 + +**加一个帧型(3 步)**:①`MuxFrame`/`HostFrame` union 加一支(可应答帧须注明 rpcId 稳定语义);②帧 schema 加一支;③消费端 fold/路由的 documented-default 已兜底未知型,按需加显式分支。 + +**加一个错误码(2 步)**:①`RpcErrorDetailsMap` 加一行(details 必填);②`rpcErrorSchema` discriminatedUnion 加一支。 + +**接一种新载体**:继承 `AbstractApiClient` 只实现 `doFetch`;需要拦截协议层(如 fixture)再覆写 `callUnary`/`openMux`/`openHost` 虚方法。契约与基类零改。 + +**升格一个预留接缝**:把预留签名抄进域接口 → map 加行 → schema 加对 → UNARY_ROUTES 加行 → impl 实现。 + +## Consequences + +所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 + +## Alternatives considered + +| 放弃项 | 一句话理由 | +|---|---| +| 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 | +| 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | +| 消费型 client 直连 ctx(省 apiproxy 一层) | 第二命令面绕开契约,wire 校验/观测/多端一致性全失;ctx 只留给前门与 headless 事件订阅两个正式用途 | +| webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | +| 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | +| 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、契约双份人肉对齐、命名无 convention 自然漂移 | +| 三信封模型(Request/Response/Frame 各一信封,签名不感知方向) | rpcId 是逻辑层关联,帧与应答的方向语义靠通道推断在换载体时即失效 | +| 具名 Request/Response 类型对为事实源(map 登记类型对) | 平铺具名类型是同一事实的第二个名字;签名 infer 反推让加方法只改一处 | +| REST 风格路径 | 消费者是自家 client,无第三方 REST 体验诉求;RPC 直映方法表更机械 | +| DTO 层(wire 专用第二套结构) | core 类型 type-only 直达浏览器零成本;DTO 是永久的双向同步税 | +| cursor 续传(mux since 实装) | 重连=重建(opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者 | +| createApiClient 工厂函数(原实现) | 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封 | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml new file mode 100644 index 0000000000..91abd32a3e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -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 +2026-07-19-gui-web-client-architecture.md: 58320570f752d4259004172d3b4527172c2cc646 +2026-07-19-gui-web-client-architecture.zh.md: 744fdaa4b89a01e2710f85b177228713189e3025 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md new file mode 100644 index 0000000000..58320570f7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -0,0 +1,148 @@ +# Agent Note: Web client architecture — the client cordis plugin tree, the slot system, and the React-free object layer + +Status: implemented + +English | [中文](2026-07-19-gui-web-client-architecture.zh.md) + +> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol RFC](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. + +## Problem + +Two forces shape the browser client. First, streaming: in an event-driven conversation UI, if business state (the event window, streaming accumulation, pending interactions, the connection state machine) scatters across React components and a global store, every token chunk shakes the render tree, and swapping the UI library means rewriting the business logic. Second, modularity: UI features (layout, sidebar, conversation, theme, locale) must be independently loadable plugins — composed at runtime from a host-served manifest, not compiled into one bundle — without giving up compile-time type safety across plugin boundaries. + +## Decision + +Both ends run cordis. The host is a cordis plugin tree; the browser runs a second, client-side cordis tree whose every UI capability is a plugin loaded dynamically by a shell-held loader. Inside that tree, cordis ctx hosts all runtime facts (services, stores, session scopes) and React is pure projection: components import nothing from the framework, receive everything through props, and subscribe to immutable snapshots via `useSyncExternalStore` (uSES below). + +``` +┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐ +│ sessions/agents/SessionLog │ │ client cordis root ctx │ +│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ loader(壳静态持有,不能经自己装载) │ +│ webserver: │ │ ├ immediately 先行组: connection/runtime/ │ +│ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(动态 bundle,并行先装) │ +│ └ GET / 注入 __DSH_BOOT__ │ │ ├ 后续组: layout/sidebar/conversation/trajectory │ +└────────────────────────────────┘ │ └ session scope ×N(观看驱动,惰性建) │ + │ React: loading 页 → settled → 整 UI 一次成型 │ + └────────────────────────────────────────────────────┘ +``` + +## The client cordis tree and the loading chain + +Every UI plugin is simultaneously a host plugin (dual-entry package): the node half sits in the host's plugin tree so the host Loader governs its lifecycle, and the browser half is a tsdown closure bundle under the package's `exports["./client"]`. The host webserver derives the boot manifest from loaded plugins carrying a `dshClient` manifest field and injects it into the page as `window.__DSH_BOOT__` — the HTML alone tells the browser everything to fetch, zero extra round trips. + +The loading chain, end to end: + +1. `GET /` → the shell boots, mounts `ctx.loader` (the loader mechanism is held statically by the shell — a loader cannot load itself; its code home is `packages/client/runtime/src/client/loader/`, imported through the `./loader` subpath so the shell bundle does not swallow the rest of the runtime package), seeds the require module table with the pure-library instances (react, react-dom, cordis, ui-slots, web-react, ui-primitives), and renders a plugin-independent loading page. +2. `loader.start()` reads `__DSH_BOOT__`. Entries flagged `immediately` form the early-load group (connection, runtime, ui-theme, i18n): fetched in parallel, applied in intra-group `inject` topological order, and **the whole group must land before anything else loads**. Remaining plugins then load in inject order. +3. Each bundle executes `window.DSHClientProxy.loadPlugin({ id, factory })`. The loader calls `factory(require)` — bundles are closure factories whose external dependencies arrive through the injected `require`, resolved against the module table (no globals, no import maps; an unresolvable specifier fails loud). The factory returns its module export surface (including the cordis `apply`); the loader runs `ctx.plugin(apply)`, then **registers that export surface into the module table under the package name**, so inject topology guarantees later plugins can `require` earlier ones. Plugin CSS is inlined in the bundle and injected as `