From 207aab9d8d5c9281444265f699576d99ec031a1c Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 18:59:26 +0800 Subject: [PATCH 01/22] fix(llm): classify empty model completions as retryable EMPTY_RESPONSE A well-formed provider stream that ends with finish_reason stop and zero content blocks previously became a successful empty assistant message: the turn completed silently, and drivers like goal-session counted the no-op round. Both adapters now map that degenerate completion to a finish {kind:'error'} with the new canonical EMPTY_RESPONSE code from dsh-llm, and dsh-llm-retry adds the code to its default retryable set, so the existing closed-step recovery path retries it and fails loud once the budget is exhausted. Covered by adapter unit tests, an llm-retry default-policy test, and a new authored keyless ACP snapshot (empty-response-retry) with a deterministic 1 ms zero-jitter retry overlay. --- ...mpty-model-response-is-retryable.i18n.yaml | 6 +++ ...07-24-empty-model-response-is-retryable.md | 36 +++++++++++++ ...24-empty-model-response-is-retryable.zh.md | 36 +++++++++++++ examples/acp-agent/retry.cordis.snapshot.yml | 41 ++++++++++++++ examples/acp-agent/retry.cordis.yml | 30 +++++++++++ examples/acp-agent/tests/acp.snapshot.ts | 9 ++++ .../snapshots/empty-response-retry/input.json | 7 +++ .../empty-response-retry/session.jsonl | 19 +++++++ .../stdout.expected.jsonl | 7 +++ packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/src/translate.ts | 15 +++++- .../llm/llm-deepseek/tests/translate.spec.ts | 47 +++++++++++++++- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/src/stream.ts | 19 +++++-- packages/llm/llm-pi-ai/tests/convert.spec.ts | 23 ++++++-- packages/llm/llm-retry/README.md | 4 +- packages/llm/llm-retry/src/index.ts | 2 +- packages/llm/llm-retry/tests/retry.spec.ts | 54 ++++++++++++++++++- packages/llm/llm/README.md | 1 + packages/llm/llm/src/error.ts | 11 ++++ 20 files changed, 355 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md create mode 100644 examples/acp-agent/retry.cordis.snapshot.yml create mode 100644 examples/acp-agent/retry.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/empty-response-retry/input.json create mode 100644 examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml new file mode 100644 index 0000000000..d1270e5474 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.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-24-empty-model-response-is-retryable.md: 1c9f6092efe7cc6702117c53ea3f1b7f14445100 +2026-07-24-empty-model-response-is-retryable.zh.md: 8a124d6ac80c751fc2dbc46f1ed4d50ec5e7348f diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md new file mode 100644 index 0000000000..1c9f6092ef --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md @@ -0,0 +1,36 @@ +# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures + +Status: implemented + +English | [中文](2026-07-24-empty-model-response-is-retryable.zh.md) + +## Problem + +Providers occasionally return a degenerate completion: a well-formed stream that carries a terminal `stop` finish and zero content blocks — no text, no reasoning, no tool calls. Before this change both adapters mapped it to a successful `{kind: 'stop'}` finish, so the loop logged an empty `assistant/message` and ended the turn as `completed`. Nothing retried, nothing failed loud, and a driver like goal-session counted the silent no-op as a consumed round. A live incident showed an openrouter-served model burning three of six goal rounds on empty completions before the goal blocked on its round limit. + +## Decision + +An adapter classifies a completed empty response as a provider-boundary failure, and retry policy treats it as transient: + +- `dsh-llm` exports the canonical code `EMPTY_RESPONSE_CODE` (`'EMPTY_RESPONSE'`) beside `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE`. +- `dsh-llm-pi-ai` (`mapStopReason`): a terminal `stop` whose assistant message has no content blocks becomes a `finish {kind: 'error'}` with that code. Context-overflow detection still wins where it applies (it is checked first and is the more actionable classification). +- `dsh-llm-deepseek` (`translate`): at `[DONE]`, a `stop` (or absent) finish with no opened blocks becomes the same error finish. Reasoning-only streams count as content and stay successful. +- `dsh-llm-retry` adds `EMPTY_RESPONSE` to `DEFAULT_RETRYABLE_CODES`: the attempt produced nothing durable, so repeating it is safe; deployments can still remove it via `retryableCodes`. + +Detection is scoped to `stop` finishes only. `max-tokens` with empty content keeps its existing meaning (pi-ai already normalizes the zero-output overflow case), `tool-calls` cannot be block-empty in practice, and error/aborted finishes already fail. + +The classification rides the existing loop machinery — `finishError` → `agent/request-error` → `dsh-llm-retry` — so no `agent-loop` change was needed, and after the retry budget exhausts, the turn fails loud with `EMPTY_RESPONSE` instead of silently completing empty. + +## Alternatives considered + +**Detect in the loop or `BlockAssembler`.** One shared implementation, but it moves provider-response judgment into the loop, against "plugins, not loop changes", and the assembler is a pure assembly algorithm. The adapter is where wire facts become harness classification, with the overflow reclassification as exact precedent. + +**A stream-transform plugin on the `llm/stream` waterfall.** Provider-neutral and one implementation, but it adds a package plus wiring for what is a boundary fact each adapter can state in a few lines, and default-on behavior would still require touching every bundle. + +**Treat whitespace-only or reasoning-only responses as empty too.** Rejected as overreach: those carry model-produced content, and misclassifying a legitimate (if useless) response as a transport-class failure risks retry loops on models that intentionally stop after reasoning. The scope is exactly "zero content blocks". + +## Consequences + +- A transiently misbehaving provider now costs a bounded retry instead of a silently wasted turn; a persistently empty model surfaces as a loud `EMPTY_RESPONSE` turn failure users can act on. +- A model that genuinely intends to say nothing (rare, but possible after a tool result) is now retried and, if consistently empty, fails the turn. This trade was accepted deliberately: an empty assistant message is indistinguishable from the provider defect and has no value to the user. +- The `empty-response-retry` ACP snapshot (an authored keyless scenario with a deterministic 1 ms zero-jitter retry overlay, `examples/acp-agent/retry.cordis.yml`) pins the product-visible arc: durable `llm/retry` event, the discarded-attempt marker, and a clean completed turn. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md new file mode 100644 index 0000000000..8a124d6ac8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md @@ -0,0 +1,36 @@ +# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures + +Status: implemented + +[English](2026-07-24-empty-model-response-is-retryable.md) | 中文 + +## Problem + +提供方偶尔会返回一种退化的 completion:流本身格式完好,携带一个终止性的 `stop` 结束,却没有任何内容块——没有文本、没有 reasoning(推理)、没有工具调用。本次改动前,两个适配器都会把它映射为成功的 `{kind: 'stop'}` 结束,于是主循环记录了一条空的 `assistant/message`,并把该轮次以 `completed` 结束。没有任何重试,也没有任何显式失败,而像 goal-session 这样的驱动方会把这次静默的空操作计为一次已消耗的 goal 轮数。一次线上事故显示,某个由 openrouter 提供的模型在触及 goal 的轮数上限而被阻塞前,把六轮 goal 中的三轮消耗在了空 completion 上。 + +## Decision + +由适配器把「已完成但为空」的响应归类为一次提供方边界失败,重试策略则将其视为瞬时性问题: + +- `dsh-llm` 在 `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE` 之外,导出规范代码 `EMPTY_RESPONSE_CODE`(`'EMPTY_RESPONSE'`)。 +- `dsh-llm-pi-ai`(`mapStopReason`):当终止性 `stop` 所对应的 assistant 消息没有内容块时,它会变成一个携带该代码的 `finish {kind: 'error'}`。上下文溢出检测在其适用场景中仍然优先(它先被检查,也是更具可操作性的归类)。 +- `dsh-llm-deepseek`(`translate`):在 `[DONE]` 处,若 `stop`(或缺失)结束且没有打开过任何块,则同样变成该错误结束。仅含 reasoning 的流算作有内容,仍视为成功。 +- `dsh-llm-retry` 把 `EMPTY_RESPONSE` 加入 `DEFAULT_RETRYABLE_CODES`:这次尝试没有产生任何持久内容,因此重复它是安全的;部署方仍可通过 `retryableCodes` 将其移除。 + +检测仅限于 `stop` 结束。内容为空的 `max-tokens` 保持其既有含义(pi-ai 已经把零输出的溢出场景归一化处理),`tool-calls` 在实践中不可能是空块,而 error/aborted 结束本身已经算失败。 + +这套归类沿用既有的主循环机制——`finishError` → `agent/request-error` → `dsh-llm-retry`——因此无需改动 `agent-loop`;在重试预算耗尽后,该轮次会以 `EMPTY_RESPONSE` 显式失败,而不再静默地以空内容完成。 + +## Alternatives considered + +**在主循环或 `BlockAssembler` 中检测。** 只需一份共享实现,但这会把对提供方响应的判断挪进主循环,违背「插件优先,而非改动主循环」,且 assembler 是纯粹的组装算法。适配器才是把协议层面的事实转化为 harness 归类的地方,而溢出重归类正是精确的先例。 + +**在 `llm/stream` waterfall(瀑布式事件)上做一个流转换插件。** 这种做法提供方无关且只需一份实现,但它为「每个适配器几行就能声明的边界事实」额外增加了一个包和相应接线,而且默认开启的行为仍需改动每一个 bundle。 + +**把仅含空白或仅含 reasoning 的响应也当作空响应。** 作为过度设计予以否决:这类响应携带了模型产生的内容,把一个合法(哪怕无用)的响应误判为传输类失败,会在那些故意在 reasoning 之后停止的模型上引发重试循环。其范围严格限定为「零内容块」。 + +## Consequences + +- 一个偶发异常的提供方现在只会花费一次有界的重试,而不再是一个被静默浪费的轮次;一个持续返回空内容的模型则会显式暴露为一次用户可据以行动的 `EMPTY_RESPONSE` 轮次失败。 +- 一个确实打算什么都不说的模型(罕见,但在一次工具结果之后有可能出现)现在会被重试,若始终为空,则该轮次失败。这个取舍是经过审慎权衡后接受的:一条空的 assistant 消息与提供方缺陷无法区分,且对用户毫无价值。 +- `empty-response-retry` ACP 快照(一个人工编写的无密钥场景,配有确定性的 1 ms 零抖动重试 overlay,`examples/acp-agent/retry.cordis.yml`)钉住了产品可见的整个过程:持久的 `llm/retry` 事件、被丢弃尝试的标记,以及一次干净的已完成轮次。 diff --git a/examples/acp-agent/retry.cordis.snapshot.yml b/examples/acp-agent/retry.cordis.snapshot.yml new file mode 100644 index 0000000000..4d7010f774 --- /dev/null +++ b/examples/acp-agent/retry.cordis.snapshot.yml @@ -0,0 +1,41 @@ +# Keyless replay for the retry overlay: disable the key-requiring DeepSeek +# adapter, insert `llm-replay`, and restate the app config with the same +# deterministic 1 ms zero-jitter retry policy as the live sibling. A config +# patch replaces the whole app config, so the base fields are restated +# verbatim (raw JSONL persistence so the harness can harvest the log). +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + llmRetry: + maxTransientRetries: 2 + initialDelayMs: 1 + maxDelayMs: 1 + jitterRatio: 0 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml new file mode 100644 index 0000000000..bbb3f81c0d --- /dev/null +++ b/examples/acp-agent/retry.cordis.yml @@ -0,0 +1,30 @@ +# Retry-scenario overlay: pin the bounded transient retry policy to a +# deterministic 1 ms zero-jitter delay so the durable `llm/retry` event +# (`delayMs`) and replay wall time stay reproducible. The overlay changes no +# tool or prompt composition, so its scenarios share the default header class. +# A config patch replaces the whole app config, so the base fields are restated +# verbatim; the model is re-pinned to `deepseek-v4-flash` like the other +# snapshot overlays because the recorded corpus was captured on flash. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + llmRetry: + maxTransientRetries: 2 + initialDelayMs: 1 + maxDelayMs: 1 + jitterRatio: 0 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e35d47cea0..696a747161 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -38,6 +38,7 @@ const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) +const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -137,6 +138,14 @@ const SCENARIOS: Scenario[] = [ headerClass: 'model-switching', }, { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true }, + // Keyless, authored (like error-finish): a live provider cannot be coaxed + // into a degenerate empty completion, so the fixture scripts the adapters' + // EMPTY_RESPONSE error finish (step 1) followed by the recovered reply + // (step 2), proving the default retry policy end to end: the durable + // llm/retry event, the ACP discarded-attempt marker, and a clean completed + // turn. Its overlay only pins a deterministic 1 ms zero-jitter delay, so it + // shares the default header class. + { name: 'empty-response-retry', hasModelTurn: true, recorded: false, configPath: RETRY_CONFIG }, // Keyless, authored (like error-finish/cancel): deterministically forcing a // LIVE model to repeat one call three times is not a stable recording, so // the fixture scripts five identical todo_write calls and pins BOTH reminder diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/input.json b/examples/acp-agent/tests/snapshots/empty-response-retry/input.json new file mode 100644 index 0000000000..edc8fdb19f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "This prompt first receives an empty completion, then a retried reply." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl new file mode 100644 index 0000000000..f164c7fe62 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -0,0 +1,19 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt first receives an empty completion, then a retried reply."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"This prompt first receives an","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}} +{"type":"step/end","seq":7,"time":0,"data":{"turn":1,"step":1}} +{"type":"llm/retry","seq":8,"time":0,"data":{"turn":1,"step":1,"retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} +{"type":"step/start","seq":9,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"Recovered."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl new file mode 100644 index 0000000000..a420e775d5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl @@ -0,0 +1,7 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"This prompt first receives an","updatedAt":"{{updatedAt}}"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n\n[Previous model attempt discarded; retrying 1/2 in 1ms: model returned a completed response with no content]\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Recovered."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5a9a1bfdbc..6b64fb2fcd 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -49,7 +49,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks. +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). ## Testing diff --git a/packages/llm/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts index f0b5eaf789..f1a6267355 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -8,7 +8,7 @@ * @module dsh-llm-deepseek/translate */ -import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' import { DONE } from './sse.ts' import type { WireChunk, WireUsage } from './types.ts' @@ -80,6 +80,8 @@ function closeBlock(block: OpenBlock): ContentBlock { * Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`. * @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated. * @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel. + * A `stop` (or absent) finish with no opened blocks is a degenerate provider completion and maps to an + * `EMPTY_RESPONSE` error finish instead of a successful empty message. */ export async function* translate(payloads: AsyncIterable): AsyncGenerator { let nextIndex = 0 @@ -102,7 +104,16 @@ export async function* translate(payloads: AsyncIterable): AsyncGenerato yield { type: 'block-end', index: block.index, block: closeBlock(block) } } if (pendingUsage) yield { type: 'usage', usage: pendingUsage } - yield { type: 'finish', reason: pendingFinish ?? { kind: 'stop' } } + const reason = pendingFinish ?? { kind: 'stop' as const } + yield { + type: 'finish', + reason: reason.kind === 'stop' && order.length === 0 + ? { + kind: 'error', + failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, + } + : reason, + } return } diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts index 4ae833dc4c..e5a98d1c67 100644 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ b/packages/llm/llm-deepseek/tests/translate.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { DONE } from '../src/sse.ts' import { mapFinishReason, mapUsage, translate } from '../src/translate.ts' @@ -203,7 +203,50 @@ describe('translate: finish and usage handling', () => { it('handles chunks with no choices at all', async () => { const chunks = await collect(translate(feed({}, DONE))) - expect(chunks).toEqual([{ type: 'finish', reason: { kind: 'stop' } }]) + expect(chunks).toEqual([{ + type: 'finish', + reason: { + kind: 'error', + failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, + }, + }]) + }) + + it('classifies an explicit stop with no opened blocks as EMPTY_RESPONSE, after usage', async () => { + const chunks = await collect(translate(feed( + firstChunk, + { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 7, completion_tokens: 0 } }, + DONE, + ))) + expect(chunks).toEqual([ + { type: 'usage', usage: { inputTokens: 7, outputTokens: 0 } }, + { + type: 'finish', + reason: { + kind: 'error', + failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, + }, + }, + ]) + }) + + it('keeps a reasoning-only stream a successful stop (any opened block counts)', async () => { + const chunks = await collect(translate(feed( + firstChunk, + { choices: [{ delta: { content: null, reasoning_content: 'mull' } }] }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + DONE, + ))) + expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) + }) + + it('leaves non-stop finishes unclassified even with no opened blocks', async () => { + const chunks = await collect(translate(feed( + firstChunk, + { choices: [{ delta: {}, finish_reason: 'length' }] }, + DONE, + ))) + expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'max-tokens' } }) }) }) diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8a6736f112..381026227b 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -45,7 +45,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state ## Vocabulary differences - pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output. -- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. +- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. A terminal `stop` whose message carries no content blocks maps to a `finish {kind:'error'}` with code `EMPTY_RESPONSE` (retried by default policy) instead of a successful empty message. - pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map. - `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers. diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 37736af716..049b10d930 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -8,7 +8,7 @@ * @module dsh-llm-pi-ai/stream */ -import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' +import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' import { isContextOverflow } from '@earendil-works/pi-ai' import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai' @@ -48,7 +48,8 @@ function classifyPiAiError(message: string): string { * @param contextWindow - resolved catalog capacity for usage-based overflow detection. * @returns the mapped harness reason. Recognized error text, `stop` usage above * `contextWindow`, and zero-output `length` usage that fills the window map - * to `CONTEXT_WINDOW_EXCEEDED`. + * to `CONTEXT_WINDOW_EXCEEDED`; a `stop` with no content blocks maps to an + * `EMPTY_RESPONSE` error. */ export function mapStopReason(message: AssistantMessage, contextWindow?: number): FinishReason { const piAiOverflow = isContextOverflow(message, contextWindow) @@ -66,7 +67,19 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number) } switch (message.stopReason) { - case 'stop': return { kind: 'stop' } + case 'stop': + // A terminal stop that produced no content blocks is a degenerate + // provider completion, not a successful (empty) assistant message. + if (message.content.length === 0) { + return { + kind: 'error', + failure: { + message: `model "${message.model}" returned a completed response with no content`, + code: EMPTY_RESPONSE_CODE, + }, + } + } + return { kind: 'stop' } case 'length': return { kind: 'max-tokens' } case 'toolUse': return { kind: 'tool-calls' } case 'aborted': return { diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 15471875d2..661a930e94 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' import { toPiContext } from '../src/context.ts' @@ -520,7 +520,22 @@ describe('mapStopReason / mapUsage', () => { ['toolUse', { kind: 'tool-calls' }], ['aborted', { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } }], ] as const)('maps %s', (stopReason, expected) => { - expect(mapStopReason(assistant({ stopReason }))).toEqual(expected) + expect(mapStopReason(assistant({ stopReason, content: [{ type: 'text', text: 'ok' }] }))).toEqual(expected) + }) + + it('classifies a completed stop with no content as an EMPTY_RESPONSE error', () => { + expect(mapStopReason(assistant({ stopReason: 'stop' }))).toEqual({ + kind: 'error', + failure: { + message: 'model "deepseek-v4-flash" returned a completed response with no content', + code: EMPTY_RESPONSE_CODE, + }, + }) + }) + + it('keeps a thinking-only stop successful (any block counts as content)', () => { + expect(mapStopReason(assistant({ stopReason: 'stop', content: [{ type: 'thinking', thinking: 'mull' }] }))) + .toEqual({ kind: 'stop' }) }) it('defaults the error message when pi-ai omits it', () => { @@ -580,7 +595,9 @@ describe('mapStopReason / mapUsage', () => { }) it('uses the resolved context window for silent and length-stop overflows', () => { - const silent = assistant({ stopReason: 'stop', usage: usage(101, 0) }) + // Non-empty content keeps the no-window branch on the successful stop path + // (an empty stop is EMPTY_RESPONSE, covered above); overflow wins over both. + const silent = assistant({ stopReason: 'stop', usage: usage(101, 0), content: [{ type: 'text', text: 'x' }] }) expect(mapStopReason(silent)).toEqual({ kind: 'stop' }) expect(mapStopReason(silent, 100)).toEqual({ kind: 'error', diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 699e7e3dad..da1084ba31 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -2,7 +2,7 @@ Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. -The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. +The default policy permits two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion (a terminal stop with zero content blocks); the attempt produced nothing durable, so repeating it is safe. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. @@ -15,7 +15,7 @@ The separately published `./invariant` companion checks that every retry record initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] ``` ## Model Experience diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 4edf22d6f2..f37cf47e7e 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -33,7 +33,7 @@ const DEFAULT_MAX_TRANSIENT_RETRIES = 2 const DEFAULT_INITIAL_DELAY_MS = 500 const DEFAULT_MAX_DELAY_MS = 10_000 const DEFAULT_JITTER_RATIO = 0.1 -const DEFAULT_RETRYABLE_CODES = Object.freeze(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']) +const DEFAULT_RETRYABLE_CODES = Object.freeze(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']) /** Deployment-owned limits and classification for transient request recovery. */ export interface Config { diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 4dc1c06bd6..6115724d07 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Fiber } from 'cordis' -import LlmService, { CallId, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -51,6 +51,25 @@ function textResponse(text: string): StreamChunk[] { ] } +/** + * A degenerate empty provider completion as an error finish chunk. Both + * adapters emit this shape and the EMPTY_RESPONSE code (the field the policy + * routes on); the message text here is the deepseek adapter's phrasing (pi-ai + * qualifies it with the model name). + */ +function emptyCompletion(): StreamChunk[] { + return [ + { type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } }, + { + type: 'finish', + reason: { + kind: 'error', + failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, + }, + }, + ] +} + async function harness( adapter: LlmAdapter, config: retry.Config = {}, @@ -158,6 +177,39 @@ describe('bounded transient retry policy', () => { }) }) + it('retries an EMPTY_RESPONSE error finish under the default retryable codes', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + emptyCompletion(), + textResponse('recovered'), + ]) + // No retryableCodes override: this proves the default policy covers the + // adapters' empty-completion classification end to end (finish-chunk error + // delivery, not a thrown stream error). + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-empty-response'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'go' }]) + const event = await scheduled + expect(event.data.failure).toEqual({ + message: 'model returned a completed response with no content', + code: EMPTY_RESPONSE_CODE, + }) + + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(500) + await idle + + expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step)) + .toEqual([2]) + expect(agent.session.deriveMessages().at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'recovered' }], + }) + }) + it('leaves partial failed chunks on their step without committing a message or tool side effect', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index beac6d5e8e..54306b14d5 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -54,6 +54,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result. - `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. - `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits. +- `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default. ### Real adapters diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 758e062895..c4eb816ff6 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -27,6 +27,17 @@ export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED' /** Canonical provider-neutral code for an exhausted account quota or balance. */ export const QUOTA_EXCEEDED_CODE = 'QUOTA' +/** + * Canonical provider-neutral code for a response that completed normally but + * carried no content blocks at all. Providers occasionally emit a degenerate + * completion (a terminal stop with zero output); adapters classify it as this + * failure instead of yielding an empty assistant message, because an empty + * message silently ends the turn with nothing for the user or the loop to act + * on. The attempt produced nothing durable, so retry policy treats it as safe + * to repeat. + */ +export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE' + /** Structured codes and plain phrases that explicitly name a context bound being exceeded. */ const STRUCTURED_CONTEXT_OVERFLOW = new RegExp( String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` From 0ffa8f97404baac692bf8f4b597387f0a343139f Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 19:45:35 +0800 Subject: [PATCH 02/22] docs: sync canonical retry docs with EMPTY_RESPONSE default Address ds-review-bot: the bounded-request-recovery Agent Note stated the shipped default carried four transient codes, and the llm-streaming contract omitted the new cross-adapter empty-response classification. Update both current-state contract docs to the five-code default and cross-link the empty-response bug-fix note. --- .../architecture/2026-06-21-bounded-llm-request-recovery.md | 4 ++-- docs/core-data-structures/llm-streaming.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 28de5eb97c..3e144828cf 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -42,7 +42,7 @@ The agent loop keeps `RequestError` as that exact error object and passes `LlmFa Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them. -The initial shared transient-code set is intentionally small: the adapters' existing `RATE_LIMIT` and `SERVER` mappings plus explicit `TIMEOUT` and `TRANSPORT` codes for the two missing remote-failure families. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum. +The initial shared transient-code set is intentionally small: the adapters' existing `RATE_LIMIT` and `SERVER` mappings plus explicit `TIMEOUT` and `TRANSPORT` codes for the two missing remote-failure families. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum. A later decision added `EMPTY_RESPONSE` as a fifth default transient code — a completed provider response with no content blocks, which both adapters now classify as an error finish; see [empty model responses are retryable](../bug-fix/2026-07-24-empty-model-response-is-retryable.md). ### Put retry policy on the existing failed-step seam @@ -62,7 +62,7 @@ interface Config { } ``` -The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. +The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above (`RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, and the later `EMPTY_RESPONSE`). The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 257ce90cda..5e82d91faf 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -61,6 +61,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt. - **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. +- **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md). - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. From 4e295221f352b4ca507e813a2c410269d5a6e29a Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 19:52:47 +0800 Subject: [PATCH 03/22] refactor(persistence): group sessions in project directories --- ...7-24-project-session-directories.i18n.yaml | 6 + .../2026-07-24-project-session-directories.md | 48 +++++++ ...26-07-24-project-session-directories.zh.md | 48 +++++++ docs/core-data-structures/persistence.md | 2 +- .../session-persistence-jsonl/README.md | 17 ++- .../session-persistence-jsonl/src/format.ts | 67 ++++++++-- .../session-persistence-jsonl/src/index.ts | 90 ++++++++----- .../tests/jsonl.spec.ts | 122 +++++++++++++----- .../tests/zstd.spec.ts | 29 +++-- packages/support/acp-snapshot/src/harness.ts | 42 +++--- .../tests/fixtures/fake-acp-agent.ts | 6 +- .../record-suite/rec-child/behavior.json | 4 +- .../record-suite/rec-pin/behavior.json | 2 +- .../suite/authored-error/behavior.json | 2 +- .../fixtures/suite/blocked-log/behavior.json | 2 +- .../fixtures/suite/pin-turn/behavior.json | 2 +- .../fixtures/suite/plain-turn/behavior.json | 4 +- .../acp-snapshot/tests/harness.spec.ts | 16 +-- 18 files changed, 366 insertions(+), 143 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-24-project-session-directories.md create mode 100644 .agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml new file mode 100644 index 0000000000..f6cd03ddfd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.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-24-project-session-directories.md: f65045419d5c749525ebdefcd1875dfc8ea69182 +2026-07-24-project-session-directories.zh.md: 1b4320d925c85b9b42a6c3ec9ee4ec52f4600786 diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md new file mode 100644 index 0000000000..f65045419d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md @@ -0,0 +1,48 @@ +# Agent Note: Project-grouped session directories + +Status: implemented + +English | [中文](2026-07-24-project-session-directories.zh.md) + +## Problem + +A persistence root may be local to one project, shared by several projects, temporary, or centralized. The hashed cwd buckets kept all deployments functional but made a shared root difficult to navigate because a developer could not recognize a project from its directory name. + +Each JSONL session also occupied one file directly inside the project bucket. That shape had no ownership directory for additional session artifacts such as metadata, attachments, spill files, or coordination state. + +## Decision + +The JSONL backend stores sessions under a readable project key and gives every session its own directory: + +```text +/ + ----/ + / + session.jsonl.zstd +``` + +Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable prefix is bounded to keep the component within filesystem limits. A short SHA-256 suffix distinguishes project paths whose readable forms collide or truncate alike. + +The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure. + +The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change. + +Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `/.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration. + +## Alternatives considered + +**Keep opaque cwd hashes.** This preserved short names but defeated the requested navigation by project path when several projects share a persistence root. + +**Put session files directly in each project directory.** This matched Claude Code and pi's basic file organization but left no session-level ownership boundary for future artifacts. + +**Replace separators without a collision suffix.** This is readable but lossy: paths containing literal `-` can collide with paths where `-` represents a separator. Retaining a short hash suffix preserves readable navigation without merging distinct projects. + +**Mandate a centralized root.** Rejected because storage placement belongs to deployment configuration. Project grouping is useful when roots are shared and harmless when they are not. + +**Load both flat and directory layouts.** Rejected under the pre-release no-compatibility stance. One accepted layout keeps identity checks and discovery deterministic. + +## Consequences + +Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every session has a directory available for future backend-owned artifacts, and existing transcript consumers still receive a file path. + +Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix plus their distinguishing hash, and moving a project still selects a different directory because the absolute cwd remains part of storage identity. diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md new file mode 100644 index 0000000000..1b4320d925 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md @@ -0,0 +1,48 @@ +# Agent Note: 按项目分组的会话目录 + +Status: implemented + +[English](2026-07-24-project-session-directories.md) | 中文 + +## 问题 + +持久化根目录可以只供一个项目使用,也可以由多个项目共享,还可以是临时目录或集中式目录。对 cwd 进行哈希得到的分桶目录能适用于所有这些部署方式,但开发者无法从目录名辨认项目,因此共享根目录难以浏览。 + +每个 JSONL 会话也直接以单个文件的形式放在项目分桶目录中。这种布局没有为元数据、附件、溢写文件或协调状态等其他会话产物提供归属目录。 + +## 决策 + +JSONL 后端按可读的项目键存储会话,并为每个会话提供独立目录: + +```text +/ + ----/ + / + session.jsonl.zstd +``` + +原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读前缀则限制长度,以确保目录项不超过文件系统限制。短 SHA-256 后缀用于区分可读形式发生冲突或被截断成相同形式的项目路径。 + +根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。 + +编码后的会话 id 用于命名归属目录,而不是 transcript(文本记录)文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path` 和 `DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。 + +延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O,首次追加会先创建项目目录和会话目录,再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `/.jsonl*` 产物;预发布格式不提供自动数据迁移。 + +## 考虑过的替代方案 + +**保留不透明的 cwd 哈希。** 这可以保持目录名简短,但当多个项目共享一个持久化根目录时,无法满足按项目路径浏览的需求。 + +**把会话文件直接放入各项目目录。** 这与 Claude Code 和 pi 的基本文件组织一致,但没有为未来产物提供会话级归属边界。 + +**替换分隔符但不添加冲突后缀。** 这种方式可读但有损:路径中的字面 `-` 可能与用 `-` 表示分隔符的路径发生冲突。保留短哈希后缀,既能让不同项目保持区分,又不会牺牲可读的浏览体验。 + +**强制使用集中式根目录。** 不予采纳,因为存储位置属于部署配置。项目分组在根目录共享时有用,在不共享时也没有负面影响。 + +**同时加载扁平布局和目录布局。** 按照预发布阶段不提供兼容性的原则,不予采纳。只接受一种布局,可以让身份检查和发现过程保持确定性。 + +## 后果 + +共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个会话都有一个可供后端未来存放自有产物的目录,而现有 transcript 消费方仍会收到文件路径。 + +项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀和用于区分的哈希;移动项目仍会选择不同的目录,因为绝对 cwd 仍是存储身份的一部分。 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f45eb0417a..12cfc31125 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -18,7 +18,7 @@ Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id ## `SessionLocation` — optional per-session artifact target -`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. +`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns the absolute transcript path inside its project/session directory; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. ```ts type-equiv /** diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index bf86bf8633..8bd704f1e2 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -6,14 +6,16 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` / - cwd-/ # per-project bucket (or _no-cwd/ when no cwd) - .jsonl.zstd # default: checksummed header frame + append frames - .jsonl # only with compression: 'none' + ----/ # readable project directory (or _no-cwd/) + / # session-owned directory + session.jsonl.zstd # default: checksummed header frame + append frames + session.jsonl # only with compression: 'none' ``` - The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. -- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). +- The project directory keeps the normalized cwd readable for navigation and adds a short SHA-256 suffix so paths that normalize alike remain distinct. Its readable prefix is bounded for filesystem component limits. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. +- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. ## Config @@ -23,17 +25,17 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence | `packChunks` | `boolean` (default `false`) | Write delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Off, the written logical layout is byte-identical to the pre-packing format; reading packed rows works regardless of this switch. Off by default while the snapshot goldens stay one-event-per-line — recording with packing on rewrites every fixture `session.jsonl`. | | `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. | -`locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix. +`locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved project/session directories. It performs no filesystem I/O: the target can be returned before the directory or file exists, and an existing file contains only the last flushed prefix. ## Physical encoding The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, followed by one checksummed frame per durable append batch. The backend uses Node's built-in Zstandard API with its default compression level and exposes no level knob. Listing reads and validates only the header frame. `compression: 'none'` keeps the same logical lines in the original raw representation. -A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. There is no migration, mixed-root fallback, or dual write. +A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. There is no migration, mixed-root fallback, or dual write. ## Durability and crash semantics -- **Bound storage identity.** Lookup requires one matching encoded filename across the cwd buckets, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append. +- **Bound storage identity.** Lookup requires one matching session directory across the readable project directories, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected transcript path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append. - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. - **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects. @@ -64,6 +66,7 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr ## Known Limitations and Deferred Work - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. +- **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). - **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 2a34a1ce80..bb55f5e00d 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -2,7 +2,7 @@ * On-disk format helpers for the JSONL session-persistence backend: path * sanitization (a {@link SessionId} is an unvalidated branded string, so it * MUST be encoded before use in a path — no traversal, no collision), the - * per-cwd directory layout, header-line (de)serialization, and the + * per-project/session directory layout, header-line (de)serialization, and the * truncation-repair offset computation. * * @module dsh-session-persistence-jsonl/format @@ -120,24 +120,65 @@ export function encodeSegment(raw: string): string { } /** - * The directory a session's files live in: the configured root, then a per-cwd - * subdirectory so sessions group by project. The cwd subdir is a stable hash of - * the cwd (short, collision-resistant, filesystem-safe); sessions without a - * cwd go in a shared `_no-cwd` bucket. - * @param root - the backend's session root directory. - * @param cwd - the session's project directory; `undefined` selects the shared `_no-cwd` bucket. - * @returns the per-cwd bucket directory path under `root`. + * Build the readable, collision-resistant directory key for a project path. + * Filesystem separators and drive separators become `-`; unsafe code units use + * the same `~XXXX` escape as session ids. The readable prefix is bounded for + * filesystem component limits, and the hash suffix keeps distinct or truncated + * paths separate. + * @param cwd - the session's project directory. + * @returns a single filesystem-safe project directory name. */ -export function sessionDir(root: string, cwd: string | undefined): string { - if (cwd === undefined) return join(root, '_no-cwd') +export function projectKey(cwd: string): string { + if (cwd.length === 0) throw new Error('cannot encode an empty project path') + let readable = '' + let separatorRun = false + for (let i = 0; i < cwd.length; i++) { + const code = cwd.charCodeAt(i) + const ch = String.fromCharCode(code) + if (ch === '/' || ch === '\\' || ch === ':') { + if (!separatorRun) readable += '-' + separatorRun = true + } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) { + readable += ch + separatorRun = false + } else { + readable += '~' + code.toString(16).toUpperCase().padStart(4, '0') + separatorRun = false + } + } const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 12) - return join(root, `cwd-${hash}`) + const slug = readable.replace(/^-+/, '') || 'root' + return `--${slug.slice(0, 200)}--${hash}` +} + +/** + * The configured root's human-navigable project directory. A configured root + * may be local or shared; this grouping does not prescribe its deployment. + * @param root - the backend's session root directory. + * @param cwd - the session's project directory; `undefined` selects `_no-cwd`. + * @returns the project directory path under `root`. + */ +export function projectDir(root: string, cwd: string | undefined): string { + if (cwd === undefined) return join(root, '_no-cwd') + return join(root, projectKey(cwd)) +} + +/** + * The directory owned by one session and available for future session-local + * artifacts. + * @param root - the backend's session root directory. + * @param cwd - the session's project directory. + * @param id - the session id, encoded to one safe path segment. + * @returns the session directory beneath its project directory. + */ +export function sessionDir(root: string, cwd: string | undefined, id: SessionId): string { + return join(projectDir(root, cwd), encodeSegment(id)) } /** * The append-only event-log file path for a session. * @param root - the backend's session root directory. - * @param cwd - the session's project directory (picks the per-cwd bucket; `undefined` → `_no-cwd`). + * @param cwd - the session's project directory (`undefined` → `_no-cwd`). * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use. * @param compression - physical artifact encoding and filename suffix. * @returns the session's configured JSONL artifact path. @@ -148,7 +189,7 @@ export function logPath( id: SessionId, compression: JsonlCompression, ): string { - return join(sessionDir(root, cwd), `${encodeSegment(id)}${logSuffix(compression)}`) + return join(sessionDir(root, cwd, id), `session${logSuffix(compression)}`) } /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 629c0e3ff1..ad58cfe145 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -19,7 +19,7 @@ import { } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { - encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, scanLog, sessionDir, toHeaderLine, + encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, toHeaderLine, type JsonlCompression, } from './format.ts' import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts' @@ -141,7 +141,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /* jscpd:ignore-end */ // --- PersistenceBackend hooks (the file-bytes storage primitives) --- - /** Read a stored prefix by id across all cwd buckets when cwd is unknown. */ + /** Read a stored prefix by id across all project directories when cwd is unknown. */ async loadStored(id: SessionId): Promise | undefined> { await this.ensureRootEncoding() const path = await this.findLog(id) @@ -278,9 +278,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await this.ensureRootEncoding() const artifacts: Array<{ header: SessionHeader; path: string }> = [] const ids = new Set() - for (const dir of await this.listCwdDirs()) { - for (const name of await this.listArtifactNames(dir)) { - const path = join(dir, name) + for (const project of await this.listProjectDirs()) { + for (const dir of await this.listSessionDirs(project)) { + const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`) + if (await this.exists(opposite)) throw this.encodingMismatch(opposite) + const path = join(dir, `session${logSuffix(this.compression)}`) + if (!await this.exists(path)) continue // Read only headers so listing scales with session count, not log size. const first = this.compression === 'zstd' ? await this.readFirstZstdLine(path) @@ -290,7 +293,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if (meta === undefined) continue // not a session header this.assertStoredIdentity(path, meta) if (ids.has(meta.id)) { - throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple cwd buckets`) + throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`) } ids.add(meta.id) artifacts.push({ header: meta, path }) @@ -303,20 +306,22 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /** Atomically write the header line + first batch (temp-write, fsync, publish). */ private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise { - const dir = sessionDir(this.root, meta.cwd) + const project = projectDir(this.root, meta.cwd) + const dir = sessionDir(this.root, meta.cwd, meta.id) const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression) await this.rejectOppositeArtifact(meta.cwd, meta.id) const content = await this.encodeMaterialization(meta, events) /* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */ if (process.platform === 'win32') { - await this.materializeWin32(dir, finalPath, meta.id, content) + await this.materializeWin32(project, dir, finalPath, meta.id, content) } else { - await this.materializePosix(dir, finalPath, meta.id, content) + await this.materializePosix(project, dir, finalPath, meta.id, content) } } /* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */ private async materializePosix( + project: string, dir: string, finalPath: string, id: SessionId, @@ -324,8 +329,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi ): Promise { await mkdir(this.root, { recursive: true, mode: 0o700 }) await this.syncDirPosix(dirname(this.root)) - await mkdir(dir, { recursive: true, mode: 0o700 }) + await mkdir(project, { recursive: true, mode: 0o700 }) await this.syncDirPosix(this.root) + await mkdir(dir, { recursive: true, mode: 0o700 }) + await this.syncDirPosix(project) await this.rejectExistingLog(finalPath, id) const tmp = await this.writeSyncedTempFile(finalPath, content) // Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the @@ -358,12 +365,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /* v8 ignore start -- native Windows coverage exercises this integration path */ private async materializeWin32( + project: string, dir: string, finalPath: string, id: SessionId, content: Buffer | string, ): Promise { await ensureDurableDirectoryWin32(this.root) + await ensureDurableDirectoryWin32(project) await ensureDurableDirectoryWin32(dir) await this.rejectExistingLog(finalPath, id) const tmp = await this.writeSyncedTempFile(finalPath, content) @@ -541,19 +550,19 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** Find the unique physical log for an id across every cwd bucket. */ + /** Find the unique physical log for an id across every project directory. */ private async findLog(id: SessionId): Promise { - const target = encodeSegment(id) + logSuffix(this.compression) - const oppositeTarget = encodeSegment(id) + logSuffix(this.oppositeCompression()) const matches: string[] = [] - for (const dir of await this.listCwdDirs()) { - const path = join(dir, target) - const opposite = join(dir, oppositeTarget) + for (const project of await this.listProjectDirs()) { + await this.rejectLegacyFlatArtifact(project, id) + const dir = join(project, encodeSegment(id)) + const path = join(dir, `session${logSuffix(this.compression)}`) + const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`) if (await this.exists(opposite)) throw this.encodingMismatch(opposite) if (await this.exists(path)) matches.push(path) } if (matches.length > 1) { - throw new Error(`duplicate JSONL session id "${id}" appears in multiple cwd buckets`) + throw new Error(`duplicate JSONL session id "${id}" appears in multiple project directories`) } return matches[0] } @@ -580,12 +589,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error }) } if (path !== expectedPath) { - throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd belong at "${expectedPath}"`) + throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`) } } - /** The cwd-bucket directories under the root (absolute paths). */ - private async listCwdDirs(): Promise { + /** The human-readable project directories under the configured root. */ + private async listProjectDirs(): Promise { try { const entries = await readdir(this.root, { withFileTypes: true }) return entries.filter(e => e.isDirectory()).map(e => join(this.root, e.name)) @@ -596,13 +605,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - private async listArtifactNames(dir: string): Promise { - const entries = await readdir(dir) - const oppositeSuffix = logSuffix(this.oppositeCompression()) - const incompatible = entries.find(name => name.endsWith(oppositeSuffix)) - if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`) - const suffix = logSuffix(this.compression) - return entries.filter(name => name.endsWith(suffix)) + /** List session-owned directories and reject the obsolete flat-file layout. */ + private async listSessionDirs(project: string): Promise { + const entries = await readdir(project, { withFileTypes: true }) + const legacy = entries.find(entry => + entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.zstd'))) + if (legacy !== undefined) throw this.legacyLayout(join(project, legacy.name)) + return entries.filter(entry => entry.isDirectory()).map(entry => join(project, entry.name)) } /** Reject a root that already belongs to the other physical encoding. */ @@ -612,11 +621,19 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } private async checkRootEncoding(): Promise { - const oppositeSuffix = logSuffix(this.oppositeCompression()) - for (const dir of await this.listCwdDirs()) { - const entries = await readdir(dir) - const incompatible = entries.find(name => name.endsWith(oppositeSuffix)) - if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`) + for (const project of await this.listProjectDirs()) { + for (const dir of await this.listSessionDirs(project)) { + const incompatible = join(dir, `session${logSuffix(this.oppositeCompression())}`) + if (await this.exists(incompatible)) throw this.encodingMismatch(incompatible) + } + } + } + + private async rejectLegacyFlatArtifact(project: string, id: SessionId): Promise { + const encoded = encodeSegment(id) + for (const compression of ['zstd', 'none'] as const) { + const path = join(project, encoded + logSuffix(compression)) + if (await this.exists(path)) throw this.legacyLayout(path) } } @@ -637,6 +654,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi ) } + private legacyLayout(path: string): Error { + return new Error( + `session artifact ${JSON.stringify(path)} uses the unsupported flat-file layout; ` + + 'use a separate root or move it into a project/session directory before loading', + ) + } + private async exists(path: string): Promise { try { const handle = await open(path, 'r') @@ -646,7 +670,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // Only ENOENT means absent. A permission/I/O error must surface rather // than letting load or collision checks proceed under false absence. // Windows reports ENOENT, not ENOTDIR, for `regular-file/child`; verify - // the immediate parent so a blocked cwd bucket remains a storage fault. + // the immediate parent so a blocked session directory remains a storage fault. /* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */ if (isENOENT(error)) { await this.assertLogParentAllowsAbsence(path) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 2b49b7d55b..0c46afc6b8 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -6,7 +6,9 @@ import { isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts' +import { + encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, toHeaderLine, +} from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -125,6 +127,18 @@ describe('SessionPersistenceJsonl: format helpers', () => { expect(() => encodeSegment('')).toThrow(/empty/) }) + it('projectKey keeps the path readable and disambiguates normalized collisions', () => { + expect(projectKey('/Users/qyj/work/deepseek-harness')).toMatch( + /^--Users-qyj-work-deepseek-harness--[a-f0-9]{12}$/, + ) + expect(projectKey('/a/b-c')).not.toBe(projectKey('/a-b/c')) + expect(projectKey('C:\\work\\agent')).toMatch(/^--C-work-agent--[a-f0-9]{12}$/) + expect(projectKey('/开发/~agent')).toMatch(/^--~5F00~53D1-~007Eagent--[a-f0-9]{12}$/) + expect(projectKey('/')).toMatch(/^--root--[a-f0-9]{12}$/) + expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(216) + expect(() => projectKey('')).toThrow(/empty project path/) + }) + it('resolves a relative custom root before locating a session', async () => { const absoluteRoot = await freshRoot() const ctx = new Context() @@ -161,15 +175,15 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await ctx.sessionPersistence.create(m) // locate() is a pure target-path calculation: neither it nor create() // materializes a file before the first append. - const dir = sessionDir(root, '/work') + const dir = sessionDir(root, '/work', m.id) await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow() expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) await ctx.sessionPersistence.append(m.id, oneTurnLog()) // now materialized + expect((await stat(dir)).isDirectory()).toBe(true) expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true) expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) - void dir }) it('keeps the same location on resume and gives a fork its own location', async () => { @@ -268,7 +282,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { const m = meta('legacy-header-delta', '/legacy') const path = rawLogPath(root, m.cwd, m.id) - await mkdir(sessionDir(root, m.cwd), { recursive: true }) + await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) await writeFile(path, [ JSON.stringify(toHeaderLine(m)), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), @@ -283,7 +297,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { const m = meta('legacy-header-fallback', '/legacy') const path = rawLogPath(root, m.cwd, m.id) - await mkdir(sessionDir(root, m.cwd), { recursive: true }) + await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) await writeFile(path, [ JSON.stringify(toHeaderLine(m)), JSON.stringify({ @@ -693,7 +707,7 @@ describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () => const log = chunkRunLog() // First turn written line-per-event by an unpacked-config writer (an old // file, hand-planted so this packed-config backend adopts it on load). - await mkdir(sessionDir(root, '/work'), { recursive: true }) + await mkdir(sessionDir(root, '/work', m.id), { recursive: true }) await writeFile(rawLogPath(root, '/work', m.id), [ JSON.stringify({ type: 'session', version: 0, id: 'mixed', createdAt: 1000, cwd: '/work', delegationDepth: 0 }), ...log.map(e => JSON.stringify(e)), @@ -789,12 +803,12 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow() }) - it('list discovers sessions across multiple cwd buckets', async () => { + it('list discovers sessions across multiple project directories', async () => { await ctx.sessionPersistence.create(meta('p1', '/projA')) await ctx.sessionPersistence.append(SessionId('p1'), oneTurnLog()) await ctx.sessionPersistence.create(meta('p2', '/projB')) await ctx.sessionPersistence.append(SessionId('p2'), oneTurnLog()) - await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd bucket + await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd project directory await ctx.sessionPersistence.append(SessionId('p3'), oneTurnLog()) const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort() @@ -805,18 +819,60 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(await ctx.sessionPersistence.list()).toEqual([]) }) - it('list skips empty and non-header .jsonl files (metadata-only read)', async () => { + it('keeps the transcript in an extensible session-owned directory', async () => { + const m = meta('owned-directory', '/project') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const dir = sessionDir(root, m.cwd, m.id) + await writeFile(join(dir, 'metadata.json'), '{}\n') + await writeFile(join(projectDir(root, m.cwd), 'README'), 'project metadata\n') + await mkdir(join(projectDir(root, m.cwd), 'reserved-session'), { recursive: true }) + + expect(await readdir(dir)).toEqual(expect.arrayContaining(['metadata.json', 'session.jsonl'])) + expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id) + expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog()) + }) + + it('rejects the obsolete flat-file layout instead of ignoring stored sessions', async () => { + const m = meta('legacy-flat', '/legacy') + const project = projectDir(root, m.cwd) + const path = join(project, `${encodeSegment(m.id)}.jsonl`) + await mkdir(project, { recursive: true }) + await writeFile(path, [ + JSON.stringify(toHeaderLine(m)), + ...oneTurnLog().map(event => JSON.stringify(event)), + '', + ].join('\n')) + + await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/unsupported flat-file layout/) + }) + + it('rejects a compressed obsolete flat-file artifact during targeted lookup', async () => { + const m = meta('legacy-compressed-flat', '/legacy') + const project = projectDir(root, m.cwd) + expect(await ctx.sessionPersistence.list()).toEqual([]) + await mkdir(project, { recursive: true }) + await writeFile(join(project, `${encodeSegment(m.id)}.jsonl.zstd`), 'legacy') + + await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/) + }) + + it('list skips empty and non-header session logs (metadata-only read)', async () => { // A real session… await ctx.sessionPersistence.create(meta('real', '/p')) await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog()) - // …alongside two junk files in the _no-cwd bucket: an EMPTY file (readFirstLine - // returns undefined) and a file whose first line is not a session header - // (parseHeaderMeta returns undefined). Both are skipped, not listed. - const bucket = join(root, '_no-cwd') - await mkdir(bucket, { recursive: true }) - await writeFile(join(bucket, 'empty.jsonl'), '') - await writeFile(join(bucket, 'notheader.jsonl'), '{"type":"turn/start"}\n') - await writeFile(join(bucket, 'badjson.jsonl'), 'not json at all\n') + // …alongside junk session directories whose fixed transcript is empty or + // lacks a header. Both remain unmaterialized and are skipped. + for (const [id, content] of [ + ['empty', ''], + ['notheader', '{"type":"turn/start"}\n'], + ['badjson', 'not json at all\n'], + ] as const) { + const path = rawLogPath(root, undefined, SessionId(id)) + await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true }) + await writeFile(path, content) + } const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort() expect(ids).toEqual(['real']) @@ -825,10 +881,10 @@ describe('SessionPersistenceJsonl: edge cases', () => { it('list reads a header line longer than the 8KB read chunk', async () => { // A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving // `readFirstLine` accumulates chunks before `list()` parses it. - const bucket = join(root, '_no-cwd') - await mkdir(bucket, { recursive: true }) + const id = SessionId('big') + await mkdir(sessionDir(root, undefined, id), { recursive: true }) const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, delegationDepth: 0, pad: 'x'.repeat(9000) }) - await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n') + await writeFile(rawLogPath(root, undefined, id), bigHeader + '\n') const ids = (await ctx.sessionPersistence.list()).map(x => x.id) expect(ids).toContain('big') }) @@ -839,30 +895,30 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx.sessionPersistence.append(m.id, oneTurnLog()) await rewriteHeader(rawLogPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' }) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd belong at/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/) }) it('list rejects a session header whose id cannot name a storage path', async () => { - const bucket = sessionDir(root, undefined) - await mkdir(bucket, { recursive: true }) - await writeFile(join(bucket, 'invalid-id.jsonl'), JSON.stringify({ + const dir = join(projectDir(root, undefined), 'invalid-id') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'session.jsonl'), JSON.stringify({ type: 'session', version: 0, id: '', createdAt: 1, delegationDepth: 0, }) + '\n') await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/) }) - it('load and list reject one id materialized in multiple cwd buckets', async () => { + it('load and list reject one id materialized in multiple project directories', async () => { const id = SessionId('duplicate') for (const cwd of ['/a', '/b']) { const m = meta(id, cwd) - await mkdir(sessionDir(root, cwd), { recursive: true }) + await mkdir(sessionDir(root, cwd, id), { recursive: true }) const content = [JSON.stringify(toHeaderLine(m)), ...oneTurnLog().map(event => JSON.stringify(event))].join('\n') + '\n' await writeFile(rawLogPath(root, cwd, id), content) } - await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple cwd buckets/) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple cwd buckets/) + await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple project directories/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple project directories/) }) it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => { @@ -985,12 +1041,12 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/) }) - it('materialization surfaces a cwd-bucket storage fault', async () => { + it('materialization surfaces a project-directory storage fault', async () => { const cwd = '/x' const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) - await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE + await writeFile(projectDir(root, cwd), 'x') // project path is now a file let s!: Session await ctx2.plugin(Object.assign((inner: Context) => { s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } }) @@ -1038,14 +1094,14 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) - it('createCore rejects an id already on disk under a DIFFERENT cwd bucket', async () => { + it('createCore rejects an id already on disk under a different project directory', async () => { // Persist the id under cwd A. const a = meta('dup-id', '/projA') await ctx.sessionPersistence.create(a) await ctx.sessionPersistence.append(a.id, oneTurnLog()) // A fresh backend creating the SAME id under cwd B must still refuse: load - // identifies by id across all buckets, so a second log would make resume - // nondeterministic. create scans every bucket, not just meta.cwd's. + // identifies by id across all projects, so a second log would make resume + // nondeterministic. create scans every project, not just meta.cwd's. const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts index fcadac1f04..a91b51ec9d 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts @@ -391,15 +391,21 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { it('skips empty, incomplete, and non-header compressed artifacts while rejecting malformed header frames', async () => { const root = await freshRoot() - const bucket = sessionDir(root, undefined) - await mkdir(bucket, { recursive: true }) - await writeFile(join(bucket, 'empty.jsonl.zstd'), '') - await writeFile(join(bucket, 'partial.jsonl.zstd'), MAGIC) - await writeFile(join(bucket, 'not-header.jsonl.zstd'), await compressZstdFrame('{"type":"turn/start"}\n')) + for (const [id, content] of [ + ['empty', Buffer.alloc(0)], + ['partial', MAGIC], + ['not-header', await compressZstdFrame('{"type":"turn/start"}\n')], + ] as const) { + const sessionId = SessionId(id) + await mkdir(sessionDir(root, undefined, sessionId), { recursive: true }) + await writeFile(logPath(root, undefined, sessionId, 'zstd'), content) + } const ctx = await mount(root) expect(await ctx.sessionPersistence.list()).toEqual([]) - await writeFile(join(bucket, 'two-lines.jsonl.zstd'), await compressZstdFrame([ + const twoLinesId = SessionId('two-lines') + await mkdir(sessionDir(root, undefined, twoLinesId), { recursive: true }) + await writeFile(logPath(root, undefined, twoLinesId, 'zstd'), await compressZstdFrame([ JSON.stringify(toHeaderLine(meta('two-lines'))), JSON.stringify({ type: 'turn/start' }), '', @@ -411,8 +417,9 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { it('rejects missing, empty, and checksum-corrupt header frames on targeted reads', async () => { const root = await freshRoot() - const bucket = sessionDir(root, undefined) - await mkdir(bucket, { recursive: true }) + for (const id of ['partial-only', 'empty-header', 'bad-checksum']) { + await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true }) + } await writeFile(logPath(root, undefined, SessionId('partial-only'), 'zstd'), MAGIC) await writeFile(logPath(root, undefined, SessionId('empty-header'), 'zstd'), await compressZstdFrame('')) const corruptHeader = Buffer.from(await compressZstdFrame(`${JSON.stringify(toHeaderLine(meta('bad-checksum')))}\n`)) @@ -453,7 +460,7 @@ describe('SessionPersistenceJsonl: encoding selection', () => { expect(await ctx.sessionPersistence.list()).toEqual([]) const loadHeader = meta('late-raw-load', '/late') - await mkdir(sessionDir(root, loadHeader.cwd), { recursive: true }) + await mkdir(sessionDir(root, loadHeader.cwd, loadHeader.id), { recursive: true }) await writeFile(logPath(root, loadHeader.cwd, loadHeader.id, 'none'), [ JSON.stringify(toHeaderLine(loadHeader)), ...oneTurnLog().map(e => JSON.stringify(e)), @@ -471,13 +478,13 @@ describe('SessionPersistenceJsonl: encoding selection', () => { await ctx.sessionPersistence.list() const header = meta('late-raw-materialize', '/late') await ctx.sessionPersistence.create(header) - await mkdir(sessionDir(root, header.cwd), { recursive: true }) + await mkdir(sessionDir(root, header.cwd, header.id), { recursive: true }) await writeFile(logPath(root, header.cwd, header.id, 'none'), [ JSON.stringify(toHeaderLine(header)), ...oneTurnLog().map(e => JSON.stringify(e)), '', ].join('\n')) await expect(ctx.sessionPersistence.append(header.id, oneTurnLog())).rejects.toThrow(/uses \.jsonl/) - expect((await readdir(sessionDir(root, header.cwd))).some(name => name.endsWith('.jsonl.zstd'))).toBe(false) + expect((await readdir(sessionDir(root, header.cwd, header.id))).some(name => name.endsWith('.jsonl.zstd'))).toBe(false) }) }) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 2821969457..d2d861ed1a 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -563,39 +563,29 @@ function latestTurnIsClosed(content: string): boolean { * `parentSession`) leads, then each subagent child by ascending `createdAt`. * * Snapshot configs select the JSONL backend's raw mode, which lays sessions - * out as `//.jsonl` (one bucket per cwd). A - * parent and its same-cwd in-process child land in the SAME bucket, so - * collecting all files across all buckets catches both. Returns `[]` if no log - * was produced (a no-session scenario). + * out as `///session.jsonl`. Recursive collection + * catches the primary and every child session. Returns `[]` if no log was + * produced (a no-session scenario). */ async function harvestSessionLogs(root: string): Promise { - let cwdDirs: string[] + let files: string[] try { - cwdDirs = await readdir(root) + files = await readdir(root, { recursive: true }) } catch { return [] } const logs: HarvestedLog[] = [] - for (const dir of cwdDirs) { - const sub = join(root, dir) - let files: string[] - try { - files = await readdir(sub) - } catch { - continue - } - for (const f of files) { - if (!f.endsWith('.jsonl')) continue - const content = await readFile(join(sub, f), 'utf8') - const firstLine = content.split('\n').find(line => line.trim().length > 0) ?? '{}' - const header = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; parentSession?: unknown } - logs.push({ - id: typeof header.id === 'string' ? header.id : '', - createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0, - ...typeof header.parentSession === 'string' ? { parentSession: header.parentSession } : {}, - content, - }) - } + for (const file of files) { + if (basename(file) !== 'session.jsonl') continue + const content = await readFile(join(root, file), 'utf8') + const firstLine = content.split('\n').find(line => line.trim().length > 0) ?? '{}' + const header = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; parentSession?: unknown } + logs.push({ + id: typeof header.id === 'string' ? header.id : '', + createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0, + ...typeof header.parentSession === 'string' ? { parentSession: header.parentSession } : {}, + content, + }) } // Primary (no parentSession) first, then children by ascending createdAt. A // scenario has exactly one top-level session. In the synchronous cut sibling diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index df3bb0b970..570a783a13 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -23,9 +23,9 @@ import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import { createInterface } from 'node:readline' -/** One scripted session log: a file path under the sessions root plus its JSONL lines. */ +/** One scripted session log: a transcript path under the sessions root plus its JSONL lines. */ interface ScriptedLog { - /** Path relative to `$DSH_SNAPSHOT_SESSIONS_ROOT`, e.g. `bucket/a.jsonl` (an empty dir segment is invalid). */ + /** Path relative to `$DSH_SNAPSHOT_SESSIONS_ROOT`, e.g. `project/session/session.jsonl`. */ file: string /** * The JSONL records. String templates `{{CWD}}` and `{{SID}}` are replaced @@ -69,7 +69,7 @@ interface Behavior { logs?: ScriptedLog[] /** Leave a stray FILE directly under the sessions root (harvest must skip it). */ strayRootFile?: boolean - /** Leave a stray non-`.jsonl` file inside a bucket (harvest must skip it). */ + /** Leave a stray non-transcript file inside a project directory (harvest must skip it). */ strayBucketFile?: boolean /** Delete the sessions root entirely (harvest must yield no logs). */ deleteSessionsRoot?: boolean diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json index fd06978be1..d98afb4865 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json @@ -1,11 +1,11 @@ { "prompt": "respond", "logs": [ - { "file": "b/parent.jsonl", "lines": [ + { "file": "b/parent/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ]}, - { "file": "b/child.jsonl", "lines": [ + { "file": "b/child/session.jsonl", "lines": [ { "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 }, { "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ]} diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json index b0ed5f1a3f..7fffecf747 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json @@ -1,7 +1,7 @@ { "prompt": "respond", "logs": [{ - "file": "b/main.jsonl", + "file": "b/main/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 600, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 4, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json index 991de99fd6..fd843a3a08 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json @@ -1,7 +1,7 @@ { "prompt": "error", "logs": [{ - "file": "b/main.jsonl", + "file": "b/main/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 500, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "turn/end", "seq": 1, "time": 9, "data": { "error": "model exploded" } } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json index 209159da7d..3c8ffc0b86 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json @@ -1,7 +1,7 @@ { "prompt": "error", "logs": [{ - "file": "b/main.jsonl", + "file": "b/main/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 400, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "hook/result", "seq": 1, "time": 8, "data": { "decision": "block", "durationMs": 37 } } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json index ad4c368e49..4de8f25b7e 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json @@ -1,7 +1,7 @@ { "prompt": "respond", "logs": [{ - "file": "b/main.jsonl", + "file": "b/main/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json index 8903d0360e..e00ca3ff28 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json @@ -2,12 +2,12 @@ "prompt": "respond", "echoWorkspace": true, "logs": [ - { "file": "b/parent.jsonl", "lines": [ + { "file": "b/parent/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 200, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 5, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, { "type": "assistant/chunk", "seq": 1, "time": 5, "data": { "turn": 1, "step": 1, "chunk": { "type": "text-delta", "index": 0, "text": "hi" } } } ]}, - { "file": "b/child.jsonl", "lines": [ + { "file": "b/child/session.jsonl", "lines": [ { "type": "session", "id": "eeeeeeee-1111-4222-8333-444444444444", "createdAt": 300, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 }, { "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ]} diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index a87e72d3d4..b1981abc9d 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -378,7 +378,7 @@ describe('runScenario', () => { const { fixtureFile } = await scenario({ permissionProbe: true, logs: [{ - file: 'bucket/main.jsonl', + file: 'project/main/session.jsonl', lines: [ { type: 'session', id: '{{SID}}', createdAt: 42, cwd: '{{CWD}}' }, { type: 'turn/start', seq: 1, time: 9, data: { turn: 1 } }, @@ -566,7 +566,7 @@ describe('runScenario', () => { prompt: 'hang-until-cancel', persistLogsOnCancel: true, logs: [{ - file: 'bucket/session.jsonl', + file: 'project/main/session.jsonl', lines: [ { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } }, @@ -591,7 +591,7 @@ describe('runScenario', () => { prompt: 'hang-until-cancel', persistLogsOnCancel: true, logs: [{ - file: 'bucket/session.jsonl', + file: 'project/main/session.jsonl', lines: [ { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, @@ -776,11 +776,11 @@ describe('runScenario', () => { // File names chosen so readdir feeds the sort children-first AND // parent-in-the-middle: the comparator then sees a parent on both // sides of a pair, plus the same-createdAt (localeCompare) tiebreak. - { file: 'b1/aa-child-c.jsonl', lines: [{ type: 'session', id: 'cccccccc-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] }, - { file: 'b1/bb-parent.jsonl', lines: [{ type: 'session', id: '{{SID}}', createdAt: 900 }] }, - { file: 'b1/cc-child-a.jsonl', lines: [{ type: 'session', id: 'aaaaaaaa-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] }, + { file: 'b1/aa-child-c/session.jsonl', lines: [{ type: 'session', id: 'cccccccc-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] }, + { file: 'b1/bb-parent/session.jsonl', lines: [{ type: 'session', id: '{{SID}}', createdAt: 900 }] }, + { file: 'b1/cc-child-a/session.jsonl', lines: [{ type: 'session', id: 'aaaaaaaa-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] }, // Missing id/createdAt fall back to ''/0; earliest child by createdAt. - { file: 'b2/orphan-fields.jsonl', lines: [{ type: 'session', parentSession: '{{SID}}' }] }, + { file: 'b2/orphan/session.jsonl', lines: [{ type: 'session', parentSession: '{{SID}}' }] }, ], }) const result = await runScenario( @@ -797,7 +797,7 @@ describe('runScenario', () => { }) it('treats an empty log file as a header-less primary with default fields', { timeout: 20_000 }, async () => { - const { fixtureFile } = await scenario({ logs: [{ file: 'b/empty.jsonl', lines: [] }] }) + const { fixtureFile } = await scenario({ logs: [{ file: 'b/empty/session.jsonl', lines: [] }] }) const result = await runScenario( { steps: boot }, { agent: AGENT, mode: 'replay', fixtureFile }, From c14f488b0059311290d90bd916297629517c137f Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 21:15:53 +0800 Subject: [PATCH 04/22] fix(persistence): use normalized project directory names --- ...7-24-project-session-directories.i18n.yaml | 4 +-- .../2026-07-24-project-session-directories.md | 10 +++--- ...26-07-24-project-session-directories.zh.md | 10 +++--- .../session-persistence-jsonl/README.md | 4 +-- .../session-persistence-jsonl/src/format.ts | 12 +++---- .../tests/jsonl.spec.ts | 33 ++++++++++++++----- 6 files changed, 45 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml index f6cd03ddfd..a848e64c8f 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.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-24-project-session-directories.md: f65045419d5c749525ebdefcd1875dfc8ea69182 -2026-07-24-project-session-directories.zh.md: 1b4320d925c85b9b42a6c3ec9ee4ec52f4600786 +2026-07-24-project-session-directories.md: 2091027e67528855a3d9722bc63347319aada678 +2026-07-24-project-session-directories.zh.md: a161cff5ac42fb67650bdff593f91b95af471d1b diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md index f65045419d..2091027e67 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md @@ -16,12 +16,14 @@ The JSONL backend stores sessions under a readable project key and gives every s ```text / - ----/ + ----/ / session.jsonl.zstd ``` -Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable prefix is bounded to keep the component within filesystem limits. A short SHA-256 suffix distinguishes project paths whose readable forms collide or truncate alike. +Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable name is bounded to keep the component within filesystem limits. + +The project key intentionally has no hash suffix. This follows the common human-readable convention used by coding agents and keeps the normalized project path as the complete directory name. The normalization is lossy: paths such as `/a/b-c` and `/a-b/c`, or long paths with the same retained prefix, share one project directory. Their distinct session ids still select separate session directories; reuse of the same session id remains a storage collision and is rejected. The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure. @@ -35,7 +37,7 @@ Lazy materialization remains tied to the transcript: `create()` performs no file **Put session files directly in each project directory.** This matched Claude Code and pi's basic file organization but left no session-level ownership boundary for future artifacts. -**Replace separators without a collision suffix.** This is readable but lossy: paths containing literal `-` can collide with paths where `-` represents a separator. Retaining a short hash suffix preserves readable navigation without merging distinct projects. +**Add a collision-resistant hash suffix.** This distinguishes paths whose normalized forms collide, but makes the directory name more than the normalized project path. The chosen convention accepts lossy project grouping in exchange for the simpler, recognizable name. **Mandate a centralized root.** Rejected because storage placement belongs to deployment configuration. Project grouping is useful when roots are shared and harmless when they are not. @@ -45,4 +47,4 @@ Lazy materialization remains tied to the transcript: `create()` performs no file Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every session has a directory available for future backend-owned artifacts, and existing transcript consumers still receive a file path. -Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix plus their distinguishing hash, and moving a project still selects a different directory because the absolute cwd remains part of storage identity. +Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix. Moving a project usually selects a different directory, but distinct cwd strings that normalize to the same name share one project directory by design. diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md index 1b4320d925..a161cff5ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md @@ -16,12 +16,14 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 ```text / - ----/ + ----/ / session.jsonl.zstd ``` -原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读前缀则限制长度,以确保目录项不超过文件系统限制。短 SHA-256 后缀用于区分可读形式发生冲突或被截断成相同形式的项目路径。 +原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读名称则限制长度,以确保目录项不超过文件系统限制。 + +项目键有意不带哈希后缀。这遵循 coding agent(编码智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。 根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。 @@ -35,7 +37,7 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 **把会话文件直接放入各项目目录。** 这与 Claude Code 和 pi 的基本文件组织一致,但没有为未来产物提供会话级归属边界。 -**替换分隔符但不添加冲突后缀。** 这种方式可读但有损:路径中的字面 `-` 可能与用 `-` 表示分隔符的路径发生冲突。保留短哈希后缀,既能让不同项目保持区分,又不会牺牲可读的浏览体验。 +**添加防冲突的哈希后缀。** 这种方式能区分规范化形式相同的路径,但会使目录名不再只是规范化后的项目路径。所选约定接受有损的项目分组,以换取更简单、易于辨认的名称。 **强制使用集中式根目录。** 不予采纳,因为存储位置属于部署配置。项目分组在根目录共享时有用,在不共享时也没有负面影响。 @@ -45,4 +47,4 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个会话都有一个可供后端未来存放自有产物的目录,而现有 transcript 消费方仍会收到文件路径。 -项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀和用于区分的哈希;移动项目仍会选择不同的目录,因为绝对 cwd 仍是存储身份的一部分。 +项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀。移动项目通常会选择不同的目录,但按设计,不同的 cwd 字符串如果规范化成相同名称,就会共用同一个项目目录。 diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 8bd704f1e2..b90b47d3f5 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -6,7 +6,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` / - ----/ # readable project directory (or _no-cwd/) + ----/ # readable project directory (or _no-cwd/) / # session-owned directory session.jsonl.zstd # default: checksummed header frame + append frames session.jsonl # only with compression: 'none' @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. -- The project directory keeps the normalized cwd readable for navigation and adds a short SHA-256 suffix so paths that normalize alike remain distinct. Its readable prefix is bounded for filesystem component limits. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. +- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. ## Config diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index bb55f5e00d..af91c67961 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -8,7 +8,6 @@ * @module dsh-session-persistence-jsonl/format */ -import { createHash } from 'node:crypto' import { join } from 'node:path' import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session' @@ -120,11 +119,11 @@ export function encodeSegment(raw: string): string { } /** - * Build the readable, collision-resistant directory key for a project path. + * Build the readable directory key for a project path. * Filesystem separators and drive separators become `-`; unsafe code units use - * the same `~XXXX` escape as session ids. The readable prefix is bounded for - * filesystem component limits, and the hash suffix keeps distinct or truncated - * paths separate. + * the same `~XXXX` escape as session ids. The key is bounded for filesystem + * component limits. Separator replacement and truncation are intentionally + * lossy, following the common human-navigable project-directory convention. * @param cwd - the session's project directory. * @returns a single filesystem-safe project directory name. */ @@ -146,9 +145,8 @@ export function projectKey(cwd: string): string { separatorRun = false } } - const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 12) const slug = readable.replace(/^-+/, '') || 'root' - return `--${slug.slice(0, 200)}--${hash}` + return `--${slug.slice(0, 251)}--` } /** diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 0c46afc6b8..5afa17f461 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -127,15 +127,13 @@ describe('SessionPersistenceJsonl: format helpers', () => { expect(() => encodeSegment('')).toThrow(/empty/) }) - it('projectKey keeps the path readable and disambiguates normalized collisions', () => { - expect(projectKey('/Users/qyj/work/deepseek-harness')).toMatch( - /^--Users-qyj-work-deepseek-harness--[a-f0-9]{12}$/, - ) - expect(projectKey('/a/b-c')).not.toBe(projectKey('/a-b/c')) - expect(projectKey('C:\\work\\agent')).toMatch(/^--C-work-agent--[a-f0-9]{12}$/) - expect(projectKey('/开发/~agent')).toMatch(/^--~5F00~53D1-~007Eagent--[a-f0-9]{12}$/) - expect(projectKey('/')).toMatch(/^--root--[a-f0-9]{12}$/) - expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(216) + it('projectKey normalizes project paths into bounded readable names', () => { + expect(projectKey('/Users/qyj/work/deepseek-harness')).toBe('--Users-qyj-work-deepseek-harness--') + expect(projectKey('/a/b-c')).toBe(projectKey('/a-b/c')) + expect(projectKey('C:\\work\\agent')).toBe('--C-work-agent--') + expect(projectKey('/开发/~agent')).toBe('--~5F00~53D1-~007Eagent--') + expect(projectKey('/')).toBe('--root--') + expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(255) expect(() => projectKey('')).toThrow(/empty project path/) }) @@ -815,6 +813,23 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(ids).toEqual(['p1', 'p2', 'p3']) }) + it('groups sessions whose cwd paths normalize to the same project directory', async () => { + const first = meta('normalized-first', '/a/b-c') + const second = meta('normalized-second', '/a-b/c') + await ctx.sessionPersistence.create(first) + await ctx.sessionPersistence.append(first.id, oneTurnLog()) + await ctx.sessionPersistence.create(second) + await ctx.sessionPersistence.append(second.id, oneTurnLog()) + + expect(projectDir(root, first.cwd)).toBe(projectDir(root, second.cwd)) + expect(await readdir(projectDir(root, first.cwd))).toEqual(expect.arrayContaining([ + encodeSegment(first.id), + encodeSegment(second.id), + ])) + expect((await ctx.sessionPersistence.list()).map(header => header.id).sort()) + .toEqual([first.id, second.id].sort()) + }) + it('list on an empty root returns nothing', async () => { expect(await ctx.sessionPersistence.list()).toEqual([]) }) From 1ffdacb2c4dd0387ecf370b0ecde41979f046126 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 21:50:11 +0800 Subject: [PATCH 05/22] fix(jsonl): handle filesystem path aliases --- ...7-24-project-session-directories.i18n.yaml | 4 +-- .../2026-07-24-project-session-directories.md | 2 ++ ...26-07-24-project-session-directories.zh.md | 2 ++ .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/index.ts | 27 +++++++++++++++---- .../session-persistence-jsonl/src/win32.ts | 6 +++-- .../tests/jsonl.spec.ts | 19 ++++++++++++- .../tests/win32.spec.ts | 9 +++++++ 8 files changed, 60 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml index a848e64c8f..321b958dc6 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.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-24-project-session-directories.md: 2091027e67528855a3d9722bc63347319aada678 -2026-07-24-project-session-directories.zh.md: a161cff5ac42fb67650bdff593f91b95af471d1b +2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2 +2026-07-24-project-session-directories.zh.md: f6bb1bd0ddb1067b68d1389182ce5b3397ad81fd diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md index 2091027e67..0aa3f513d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md @@ -25,6 +25,8 @@ Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesys The project key intentionally has no hash suffix. This follows the common human-readable convention used by coding agents and keeps the normalized project path as the complete directory name. The normalization is lossy: paths such as `/a/b-c` and `/a-b/c`, or long paths with the same retained prefix, share one project directory. Their distinct session ids still select separate session directories; reuse of the same session id remains a storage collision and is rejected. +Case-insensitive filesystems can also make differently cased project keys refer to one physical directory. Identity validation accepts such an alternate spelling only when filesystem canonicalization resolves the discovered and expected paths to the same transcript. A different canonical path remains corruption, so case aliases do not weaken the same-id collision check on case-sensitive stores. + The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure. The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change. diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md index a161cff5ac..f6bb1bd0dd 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md @@ -25,6 +25,8 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 项目键有意不带哈希后缀。这遵循 coding agent(编码智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。 +在不区分大小写的文件系统上,大小写不同的项目键也可能指向同一个物理目录。只有当文件系统路径规范化将发现路径和预期路径解析为同一个 transcript 时,身份验证才接受这种拼写变体。规范化后的路径如果不同,仍视为存储损坏,因此大小写别名不会让区分大小写的存储放宽同一 id 的冲突检查。 + 根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。 编码后的会话 id 用于命名归属目录,而不是 transcript(文本记录)文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path` 和 `DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。 diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index b90b47d3f5..a665b688ab 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. -- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. +- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. ## Config diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index ad58cfe145..69b1d371d7 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -9,7 +9,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { readdirSync } from 'node:fs' -import { open, mkdir, readFile, readdir, link, rm, stat, truncate } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { @@ -168,7 +168,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi : {}, } } - this.assertStoredIdentity(path, prefix.meta, expectedId) + await this.assertStoredIdentity(path, prefix.meta, expectedId) return prefix } @@ -291,7 +291,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) if (meta === undefined) continue // not a session header - this.assertStoredIdentity(path, meta) + await this.assertStoredIdentity(path, meta) if (ids.has(meta.id)) { throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`) } @@ -578,7 +578,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** Reject metadata that does not identify the selected physical log. */ - private assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): void { + private async assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): Promise { if (expectedId !== undefined && meta.id !== expectedId) { throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`) } @@ -588,11 +588,28 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } catch (error) { throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error }) } - if (path !== expectedPath) { + if (path !== expectedPath && !await this.sameFile(path, expectedPath)) { throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`) } } + /** + * Whether two path spellings resolve to the same physical file. This admits + * case aliases on case-insensitive filesystems without weakening identity + * checks on case-sensitive stores. + */ + private async sameFile(path: string, expectedPath: string): Promise { + try { + const [actual, expected] = await Promise.all([realpath(path), realpath(expectedPath)]) + return actual === expected + } catch (error) { + /* v8 ignore else -- non-ENOENT realpath failures require an external permission or I/O fault */ + if (isENOENT(error)) return false + /* v8 ignore next -- non-ENOENT realpath failures are external I/O faults, propagated unchanged */ + throw error + } + } + /** The human-readable project directories under the configured root. */ private async listProjectDirs(): Promise { try { diff --git a/packages/session-persistence/session-persistence-jsonl/src/win32.ts b/packages/session-persistence/session-persistence-jsonl/src/win32.ts index a8c1b6fb8d..5b2b034574 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/win32.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/win32.ts @@ -12,7 +12,7 @@ */ import { mkdtemp, rm, stat } from 'node:fs/promises' -import { basename, join, parse, resolve, toNamespacedPath } from 'node:path' +import { join, parse, resolve, toNamespacedPath } from 'node:path' type MoveFileExW = (existing: string, replacement: string, flags: number) => number type GetLastError = () => number @@ -139,7 +139,9 @@ export async function ensureDurableDirectoryWin32(target: string): Promise } async function createLeafDirectoryWin32(parent: string, target: string): Promise { - const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`)) + // Keep the staging component independent of the target basename so a legal + // 255-byte target component does not make mkdtemp's sibling name too long. + const staging = await mkdtemp(join(parent, '.dsh-mkdir-')) try { await publishNewFileWin32(staging, target) } catch (error) { diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 5afa17f461..6f4da5fbfd 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -913,6 +913,23 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/) }) + it('accepts an alternate project path only when it identifies the same physical log', async () => { + const m = meta('physical-alias', '/stored') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const path = rawLogPath(root, m.cwd, m.id) + const aliasCwd = '/alias' + await symlink( + projectDir(root, m.cwd), + projectDir(root, aliasCwd), + process.platform === 'win32' ? 'junction' : 'dir', + ) + await rewriteHeader(path, (header) => { header.cwd = aliasCwd }) + + expect((await ctx.sessionPersistence.load(m.id)).meta.cwd).toBe(aliasCwd) + expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id) + }) + it('list rejects a session header whose id cannot name a storage path', async () => { const dir = join(projectDir(root, undefined), 'invalid-id') await mkdir(dir, { recursive: true }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts index b4a2d11f28..647ff8b292 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts @@ -151,6 +151,15 @@ describe('Windows durable namespace helpers', () => { expect(existsSync(raced)).toBe(true) }) + it('keeps staging names valid for a maximum-length target component', async () => { + const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove() + const root = await tempRoot() + const target = join(root, 'x'.repeat(255)) + + await ensureDurableDirectoryWin32(target) + expect(existsSync(target)).toBe(true) + }) + it('surfaces directory publication failures other than an existing-target race', async () => { const { ensureDurableDirectoryWin32 } = await importWithError(ERROR_ACCESS_DENIED) const root = await tempRoot() From fac6c35e9a54ef20a2f4d185ca6cbb43a2a0188b Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 13:02:37 +0800 Subject: [PATCH 06/22] Trim redundant source comments --- apps/web/src/node-module-stub.ts | 8 +- apps/web/tests/smoke-real.e2e.ts | 6 +- docs/config-catalog.md | 20 ++-- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 37 ++------ docs/core-data-structures/subagent.zh.md | 37 ++------ examples/acp-agent/tests/acp.e2e.ts | 3 +- examples/acp-agent/tests/hooks.e2e.ts | 2 +- .../client/connection/src/client/index.ts | 14 +-- packages/client/connection/src/index.ts | 8 +- packages/client/i18n/src/client/index.ts | 13 +-- packages/client/i18n/src/index.ts | 9 +- .../runtime/src/client/contract/store.ts | 6 +- packages/client/runtime/src/client/index.ts | 38 ++------ .../src/client/sessions/fold-adapter.ts | 8 +- .../runtime/src/client/sessions/service.ts | 3 +- .../runtime/src/client/sessions/session.ts | 23 ++--- packages/client/runtime/src/index.ts | 9 +- .../runtime/tests/sessions-service.spec.ts | 3 +- .../ui-conversation/src/client/apply.ts | 27 +----- .../src/client/chat/StatsLine.tsx | 7 +- .../src/client/contract/slots.ts | 26 +---- .../src/client/contract/views.ts | 19 +--- .../ui-conversation/src/client/index.ts | 11 +-- .../ui-conversation/src/client/service.ts | 14 +-- .../src/client/skeleton/InputBar.tsx | 13 +-- .../ui-conversation/src/client/stores.ts | 27 +----- packages/client/ui-conversation/src/index.ts | 10 +- .../tests/apply-inject.spec.tsx | 1 - .../ui-conversation/tests/chat-store.spec.ts | 7 +- .../tests/chat-toolview-slot.spec.tsx | 2 - .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 5 - packages/client/ui-conversation/tests/hook.ts | 10 +- .../tests/selection-survival.spec.ts | 13 +-- packages/client/ui-layout/src/index.ts | 10 +- .../client/ui-layout/tests/app-frame.spec.tsx | 2 +- packages/client/ui-primitives/src/index.ts | 4 +- .../ui-question/tests/browser-plugin.spec.ts | 4 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 14 +-- .../client/ui-sidebar/src/client/index.ts | 21 +--- packages/client/ui-sidebar/src/client/tree.ts | 9 +- packages/client/ui-sidebar/src/index.ts | 10 +- .../client/ui-sidebar/tests/apply.spec.tsx | 3 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 3 +- packages/client/ui-slots/src/index.ts | 13 +-- packages/client/ui-slots/src/renderer.ts | 12 +-- packages/client/ui-slots/src/store.ts | 16 +--- packages/client/ui-theme/src/client/index.ts | 8 +- packages/client/ui-theme/src/index.ts | 9 +- .../client/ui-trajectory/src/client/index.ts | 10 +- packages/client/ui-trajectory/src/index.ts | 10 +- .../client/ui-trajectory/tests/views.spec.tsx | 3 +- packages/client/web-react/src/index.ts | 13 +-- .../client/web-react/src/scoped-slots.tsx | 19 +--- .../client/web-react/src/session-provider.tsx | 21 ++-- packages/client/web-react/tests/bind.spec.tsx | 6 +- .../tests/stale-authorization.spec.tsx | 6 +- packages/client/web/src/app-shell.ts | 19 +--- packages/client/web/src/platform.ts | 8 +- packages/core/agent-loop/tests/agent.spec.ts | 9 +- packages/core/agent-loop/tests/cancel.spec.ts | 2 - .../tests/contract-regressions.spec.ts | 2 - packages/core/session/tests/surface.spec.ts | 4 - packages/core/tools/tests/tools.spec.ts | 3 - packages/fs/fs-local/src/fsio.ts | 4 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 11 +-- .../tests/repeat-tool-guard.spec.ts | 3 +- .../hooks-claude/tests/coverage-cases.ts | 5 - .../host/webserver/tests/web-plugins.spec.ts | 1 - packages/mcp/mcp-client/src/index.ts | 18 ++-- packages/mcp/mcp-client/tests/apply.spec.ts | 2 - .../mcp/mcp-client/tests/mcp-client.e2e.ts | 5 +- .../mcp/mcp-client/tests/mcp-client.spec.ts | 1 - .../subagent-acp/tests/subagent-acp.e2e.ts | 2 +- .../subagent-spawn/tests/spawn.e2e.ts | 11 +-- packages/subagent/subagent/src/types.ts | 43 +++------ packages/ui/acp/src/index.ts | 95 ++++--------------- packages/ui/acp/tests/bridge.spec.ts | 5 - packages/ui/acp/tests/config-options.spec.ts | 2 +- packages/ui/acp/tests/dispose.spec.ts | 29 +----- packages/ui/acp/tests/properties.spec.ts | 7 +- packages/ui/tui/tests/tui.spec.ts | 5 +- 83 files changed, 219 insertions(+), 748 deletions(-) diff --git a/apps/web/src/node-module-stub.ts b/apps/web/src/node-module-stub.ts index c64f307f7c..0a9b04ea5f 100644 --- a/apps/web/src/node-module-stub.ts +++ b/apps/web/src/node-module-stub.ts @@ -1,10 +1,6 @@ /** - * Browser stand-in for `node:module`, mapped by the vite alias in - * vite.config.ts (design §2.4). The vendored Loader's internal.ts imports - * `createRequire` at module scope but only calls it inside - * `ModuleLoader.fromInternal()`, whose version probe is compiled to the - * `"0.0.0"` define in the browser build — so this throw is a fail-loud - * tripwire for any path that would genuinely need Node's module machinery. + * Browser stand-in for `node:module`. `createRequire` is unreachable in the + * configured loader path and fails loud if that assumption changes. */ /** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */ diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 9cd34530be..a3d511df16 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -333,10 +333,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.` await input.fill(prompt) await input.press('Enter') - // startSession chain: session mounts, composer moves to the bottom. - // Regression pin (P0, 585671106): this send used to white-screen the tree - // (scope tag lost to a duplicate inlined runtime instance) — body going - // near-empty here means that class of bug is back. + // The first send must keep the session tree mounted; a near-empty body + // reveals a duplicate runtime bundle with incompatible scope tags. await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 }) expect(pageErrors).toEqual([]) await page.waitForFunction( diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..e2d434d532 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:285`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:275`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -710,12 +710,12 @@ Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src Requires: `tools` ```ts config-catalog -/** Discriminated union of all supported MCP transport configurations. */ +/** Configuration for one stdio or Streamable HTTP MCP server. */ export type Config = StdioConfig | StreamableHttpConfig /** Config for connecting to an MCP server via a spawned child process over stdio. */ export interface StdioConfig { - /** Transport type: spawn a child process and communicate over stdio. */ + /** Selects child-process stdio transport. */ transport: 'stdio' /** * Stable local namespace for this server's model-facing tool names @@ -723,21 +723,21 @@ export interface StdioConfig { * unique across live mcp-client instances. */ serverName: string - /** Executable to spawn. */ + /** Executable used to start the server. */ command: string - /** Arguments passed to the command. */ + /** Arguments passed directly, without shell interpolation. */ args: string[] /** Extra env vars merged on top of scrubbed ambient env. */ env: Record /** Working directory for the child process. */ cwd: string - /** Timeout per callTool invocation (ms). */ + /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ export interface StreamableHttpConfig { - /** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ + /** Selects Streamable HTTP transport. */ transport: 'streamable-http' /** * Stable local namespace for this server's model-facing tool names @@ -745,11 +745,11 @@ export interface StreamableHttpConfig { * unique across live mcp-client instances. */ serverName: string - /** MCP server URL. */ + /** MCP endpoint URL. */ url: string - /** Extra headers (e.g. auth tokens). */ + /** Additional headers attached to MCP requests. */ headers: Record - /** Timeout per callTool invocation (ms). */ + /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number } ``` diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index d8d6a493d7..fcd9e2f4f7 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.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 -subagent.md: 0335a3f0780ae17b57ae730f5a49a269261c8073 -subagent.zh.md: dac48b624f6e0cfc28737e3e1a2774ba2d97e85b +subagent.md: fda4b4b738c648c893c65a633e4a0d6a1761424f +subagent.zh.md: ba43789a3e4efe59b197f6454c977db52d90aca1 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 0335a3f078..fda4b4b738 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -22,13 +22,9 @@ A provider advertises its **start-time** features on a static descriptor the ser * is the capability. */ interface SubagentCapabilities { - /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ readonly outputSchema: boolean - /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */ readonly depthLimit: boolean - /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ readonly toolFilter: boolean - /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */ readonly persona: boolean } ``` @@ -45,16 +41,11 @@ The tool layer builds this request from the model input and its own config; the * passes it to {@link SubagentProvider.start}. */ interface SubagentStartRequest { - /** The task/prompt for the child agent (a user message in the child session). */ + /** Content delivered as the child's user message. */ readonly prompt: ContentBlock[] /** - * The spawning ("parent") agent — the one whose tool call started this - * subagent. REQUIRED: in-process backends read `parent.session.header` for - * the working directory, the `parentSession` lineage to stamp on the child, - * and the parent's delegation depth. The out-of-process backend (ACP) reads - * exactly one field — the session header's cwd, the child's workspace when - * no deployment `cwd` override is configured; nothing else crosses the - * process boundary. + * The spawning agent. In-process providers derive workspace, lineage, and + * delegation depth from its durable session state; ACP uses only its cwd. */ readonly parent: Agent /** @@ -65,7 +56,6 @@ interface SubagentStartRequest { * afterward. */ readonly signal: AbortSignal - /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects @@ -135,15 +125,12 @@ interface SubagentResult { * non-`completed` result to an `isError` tool result. */ interface SubagentStopReasonMap { - /** The child finished its turn normally. */ completed: 'completed' - /** The run was cancelled by its request signal or by disposal. */ + /** Cancelled through the request signal or disposal. */ aborted: 'aborted' - /** The child failed (model error, transport error). */ + /** Model or transport failure. */ error: 'error' - /** The child hit its token ceiling before finishing. */ 'max-tokens': 'max-tokens' - /** The child declined the task. */ refusal: 'refusal' } ``` @@ -180,9 +167,8 @@ interface SubagentRun { */ readonly result: Promise /** - * Cancel remaining work, reach child quiescence, and release the run's - * resources (in-process: dispose the owned agent and remove its session; - * ACP: kill and reap the subprocess). Idempotent. + * Cancel remaining work, reach child quiescence, and release resources. + * Idempotent. */ dispose(): Promise /** @@ -206,12 +192,9 @@ Each provider is a named child-agent transport, and multiple providers may coexi ```ts type-equiv /** - * A subagent backend: one transport for running a child agent (in-process - * spawn/fork, ACP to another process, …). Implementations register under a - * unique name via {@link SubagentService.registerProvider}; multiple providers - * coexist in one context (unlike the single-implementation bash seam). The - * Providers are trusted same-process implementations; callers treat their - * descriptors and returned values as borrowed immutable data. + * One registered transport for running child agents. Providers are trusted + * same-process implementations; callers treat descriptors and returned values + * as borrowed immutable data. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index dac48b624f..ba43789a3e 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -22,13 +22,9 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [ba * is the capability. */ interface SubagentCapabilities { - /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ readonly outputSchema: boolean - /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */ readonly depthLimit: boolean - /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ readonly toolFilter: boolean - /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */ readonly persona: boolean } ``` @@ -45,16 +41,11 @@ interface SubagentCapabilities { * passes it to {@link SubagentProvider.start}. */ interface SubagentStartRequest { - /** The task/prompt for the child agent (a user message in the child session). */ + /** Content delivered as the child's user message. */ readonly prompt: ContentBlock[] /** - * The spawning ("parent") agent — the one whose tool call started this - * subagent. REQUIRED: in-process backends read `parent.session.header` for - * the working directory, the `parentSession` lineage to stamp on the child, - * and the parent's delegation depth. The out-of-process backend (ACP) reads - * exactly one field — the session header's cwd, the child's workspace when - * no deployment `cwd` override is configured; nothing else crosses the - * process boundary. + * The spawning agent. In-process providers derive workspace, lineage, and + * delegation depth from its durable session state; ACP uses only its cwd. */ readonly parent: Agent /** @@ -65,7 +56,6 @@ interface SubagentStartRequest { * afterward. */ readonly signal: AbortSignal - /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects @@ -135,15 +125,12 @@ interface SubagentResult { * non-`completed` result to an `isError` tool result. */ interface SubagentStopReasonMap { - /** The child finished its turn normally. */ completed: 'completed' - /** The run was cancelled by its request signal or by disposal. */ + /** Cancelled through the request signal or disposal. */ aborted: 'aborted' - /** The child failed (model error, transport error). */ + /** Model or transport failure. */ error: 'error' - /** The child hit its token ceiling before finishing. */ 'max-tokens': 'max-tokens' - /** The child declined the task. */ refusal: 'refusal' } ``` @@ -182,9 +169,8 @@ interface SubagentRun { */ readonly result: Promise /** - * Cancel remaining work, reach child quiescence, and release the run's - * resources (in-process: dispose the owned agent and remove its session; - * ACP: kill and reap the subprocess). Idempotent. + * Cancel remaining work, reach child quiescence, and release resources. + * Idempotent. */ dispose(): Promise /** @@ -208,12 +194,9 @@ interface SubagentRun { ```ts type-equiv /** - * A subagent backend: one transport for running a child agent (in-process - * spawn/fork, ACP to another process, …). Implementations register under a - * unique name via {@link SubagentService.registerProvider}; multiple providers - * coexist in one context (unlike the single-implementation bash seam). The - * Providers are trusted same-process implementations; callers treat their - * descriptors and returned values as borrowed immutable data. + * One registered transport for running child agents. Providers are trusted + * same-process implementations; callers treat descriptors and returned values + * as borrowed immutable data. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 46592e4704..37154d9fb1 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -114,11 +114,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify the WORLD, not the agent's self-report: read the file from disk. + // Assert the filesystem effect independently of the model response. const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') expect(proof).toContain('ACP_OK') - // And the client saw tool-call activity stream through. const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call') expect(toolCalls.length).toBeGreaterThan(0) diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index 528823f3c5..d8f05291ff 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -62,7 +62,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook // the model, not a turn failure). expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify that the denied hook left no filesystem effect. + // Assert the denied operation independently of the model response. await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow() // A blocked call is still streamed with the hook's reason as an error. diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index b017d1c9e2..673aa978da 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,10 +1,7 @@ /** - * Browser half of the wire consumer layer (contract: api-contracts v3 - * section 3; export inventory = v3 §3.2). The wire is this package's client - * half in its entirety — apply mounts ctx.connection: the shared api client - * plus the connection controller handle. Mode selection (?fixture) happens - * here so the rest of the client tree is mode-blind; the controller's sinks - * are wired by the runtime plugin (object layer), which injects this service. + * Browser wire client. The plugin selects fixture or HTTP transport, provides + * the shared API client, and lets the runtime object layer start the stream + * controller with its sinks. */ import type { Context } from 'cordis' import type { IApiClient } from './api.ts' @@ -23,9 +20,8 @@ export type { } from './api.ts' export { RpcId, AbstractApiClient, transportError } from './api.ts' -// ---- Connection loop types (part of the ConnectionHandle.start contract; -// the controller class itself stays package-internal — apply owns the loop, -// tests reach it via src) ---- +// Connection loop types are public through ConnectionHandle.start; the +// controller remains package-internal. export type { ConnectionConfig, ConnectionSinks, ConnectionState } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 313db07225..16074233e7 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,10 +1,4 @@ -/** - * Connection plugin, node half. The package IS a dshClient plugin: the wire - * consumer layer lives in its client half in full (src/client/ — contract: - * api-contracts v3 section 3, inventory §3.2); consumers import the /client - * subpath. The empty apply exists so the plugin appears in the host Loader - * (lifecycle governance + dshClient discovery). - */ +/** Host loader entry for the browser wire client exported from `./client`. */ /** Host plugin body — no host-side behavior for the connection plugin. */ export function apply(_ctx: unknown): void {} diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts index 37e1c0cdb5..9dea9c4bd4 100644 --- a/packages/client/i18n/src/client/index.ts +++ b/packages/client/i18n/src/client/index.ts @@ -1,15 +1,10 @@ /** - * i18n plugin, browser half: namespace x locale dictionary registry with a - * bound translate function whose reference is stable (safe for inject - * surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries. - * Contract: api-contracts v3 section 8. + * Browser-side locale registry. Bound translation functions retain stable + * identity for injected consumers. */ import type { Context } from 'cordis' -// The snapshot-store engine lives in runtime (store relocation): framework -// data stores like this locale cell use it directly. The store carries no -// hook — a React consumer binds a selector hook via web-react's -// bindSnapshotSelector at its own seam (none exists today; the current -// consumers are translate() reads and test-side subscribe/set). +// Snapshot stores are framework-neutral; React consumers bind hooks at their +// rendering boundary. import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { en } from '../locales/en.ts' diff --git a/packages/client/i18n/src/index.ts b/packages/client/i18n/src/index.ts index 1e2de41ace..e759f1edc1 100644 --- a/packages/client/i18n/src/index.ts +++ b/packages/client/i18n/src/index.ts @@ -1,11 +1,4 @@ -/** - * i18n plugin, node half. Pure UI plugin: the empty apply exists so the - * plugin appears in the host cordis.yml / Loader (load and lifecycle follow - * the host; the browser half ships via exports["./client"], discovered - * through the package.json dshClient declaration). Everything else — - * I18nService, Translate, LocaleDict — lives in the client half; consumers - * import the /client subpath. Contract: api-contracts v3 section 8. - */ +/** Host loader entry for the browser implementation exported from `./client`. */ /** Host plugin body — no host-side behavior for the i18n plugin. */ export function apply(): void {} diff --git a/packages/client/runtime/src/client/contract/store.ts b/packages/client/runtime/src/client/contract/store.ts index ce4444cf36..7dc3b584ba 100644 --- a/packages/client/runtime/src/client/contract/store.ts +++ b/packages/client/runtime/src/client/contract/store.ts @@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void { } } -// ---- defineStore shell (slot terminal design §4) ---- -// The type authority is ui-slots' store family (create(scopeKey?) and -// clearPersisted() included); this module houses only the engine-backed -// implementation. The one engine-side widening left: instances expose the -// raw engine store for framework/test surfaces. +// ui-slots owns the contract; this module supplies the engine implementation. /** A live engine instance: the contract instance plus the raw engine store. */ export interface EngineStoreInstance> extends StoreInstance { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 33097d4573..4c0bf3d01f 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,12 +1,7 @@ /** - * Browser half: the whole runtime contract surface (api-contracts v3 §4) — - * SlotsService (declaration ledger + renderer seam + store axis, built-in - * 'root'), SessionsService (list store + current selection + scope tree + - * object layer), and the cordis Context/Events merges. apply mounts - * ctx.slots + ctx.sessions and wires the connection stream loop into the - * object layer. A static-arrival entry: the web shell bundles this module - * and mounts it through the host graph (module loading lives in - * @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader). + * Browser runtime services for slots, sessions, and connection-stream + * delivery. The web shell mounts this static client entry through the host + * plugin graph. */ import type { Context } from 'cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' @@ -17,15 +12,11 @@ import type { SessionListState } from './sessions/service.ts' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' export { SlotsService } from './slots.ts' -// RootOwnerProps rides the 'root' SlotMap row (both migrated here from -// ui-layout: the framework slot is declared by the framework package). export type { RootOwnerProps } from './slots.ts' export { SessionsService, scopeOf } from './sessions/service.ts' export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' -// The snapshot-store engine lives here since the store migration (the data -// layer owns its substrate; web-react is React glue only). The './client' -// main export is the single serving door — no store subpath. +// Runtime owns the snapshot store; web-react only binds it to React. export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, @@ -35,21 +26,11 @@ export type { RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' -// PendingWait is a value export: tests construct fixture waits directly. export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -// ---- Narrowed aliases (the single narrowing point of the slot type chain: -// ui-slots/web-react stay generic and dependency-inverted; the client-tree -// concrete types live here, where their subjects live) ---- - -/** - * The client cordis context face: the base Context plus the service keys - * this package's declaration merge contributes (slots/sessions/loader) and - * every later plugin's merge. A plain alias — the merges land on Context - * itself inside the client program; the name marks intent at consumer seams. - */ +/** Client-side Cordis context after declaration merging. */ export type ClientContext = Context /** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */ @@ -69,14 +50,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * every session-scope slot component receives these from the framework. */ interface SessionStandardProps { - /** Selector hook over this session's conversation snapshot. */ useSession: SnapshotSelectorHook /** The framework-resolved session id (owners never pass it). */ sessionId: SessionId } - /** Global standard kit, real members: the session-list hook every slot component receives. */ + /** Props injected into every global slot component. */ interface GlobalStandardProps { - /** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */ useSessions: SnapshotSelectorHook } } @@ -99,9 +78,8 @@ declare module 'cordis' { /** Required services: the wire handle mounted by the connection plugin. */ export const inject = ['connection'] -/** - * Client plugin body: mount slots + sessions, start the stream loop. - * @param ctx - client cordis context. +/** Mounts the browser runtime services and connection stream. + * @param ctx - Client Cordis context. */ export function apply(ctx: Context): void { ctx.plugin(SlotsService) diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 23b35f86bf..0f40d9bf2a 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -24,10 +24,10 @@ export interface CallIndexEntry { callView: ToolCallView | null } -/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch). - * 'noop/padding' is not a real event type on purpose: a genuine type with fake data would - * surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one - * place a synthetic event enters the window). */ +/** Non-surface sentinel used to preserve paged-window sequence offsets. + * `noop/padding` is deliberately not a real event type, so it cannot acquire + * surface behavior; this cast is the only synthetic event entry point. + */ function paddingEvent(seq: number): SessionEvent { return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index d8a6f05762..af07362f08 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -291,8 +291,7 @@ export class SessionsService { fiber, ctx, binding: { sessionId: id, session, ctx }, - // Bare source form (store migration): the Session object IS the - // observable; the React side binds the useSession hook per cell. + // Session is the observable; React binds a selector hook at its own seam. cell: { sessionId: id, session }, } this.scopes.set(id, record) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 6b773e0903..0681e2bb5f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,7 +1,4 @@ -// Session: wraps every contract call that needs a sessionId + all conversation state for this -// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once -// created, they keep consuming mux frames in the background; React connects directly via -// subscribe/getSnapshot. +// Sessions remain resident after creation so they continue consuming mux frames off-screen. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' @@ -22,14 +19,12 @@ import { FoldAdapter } from './fold-adapter.ts' import { Notifier } from './notifier.ts' import { PartialAccumulator } from './partial.ts' -/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */ +/** Messages requested per history page. */ export const PAGE_MESSAGES = 50 /** - * Per-session state owner: event window + fold + partial, snapshot out via - * subscribe/getSnapshot (see the web client architecture RFC). Bare source - * only (store migration): the React machinery binds the per-cell useSession - * hook at its own seam — no selector hook member lives on the data layer. + * Owns a session's event window, derived conversation state, and observable + * snapshot. React bindings remain outside this data layer. */ export class Session implements ObservableSnapshot { // ---- Window and derived state (all private; the snapshot is the only read surface) ---- @@ -54,8 +49,7 @@ export class Session implements ObservableSnapshot { * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ private frozenNodes: ConversationNode[] = [] private pending = new Map() - // Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2, - // audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so + // Revision counters preserve array identity when derived content is unchanged, so // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every // tool card and pending card). Mutation sites bump the matching revision. partial needs no // counter — PartialAccumulator.toPartial already returns a cached reference when unchanged. @@ -69,9 +63,9 @@ export class Session implements ObservableSnapshot { private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null - /** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */ + /** Live events buffered during open/resync and stitched by sequence once history lands. */ private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] - /** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */ + /** Gap repair in flight; live events detour to the buffer until the tail page lands. */ private stitching = false /** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */ private subscribedLastSeq: number | null = null @@ -292,8 +286,7 @@ export class Session implements ObservableSnapshot { this.notifier.markDirty() } - /** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed - * in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */ + /** No-op because session instances remain resident. */ dispose(): void {} // ---- 私有 ---- diff --git a/packages/client/runtime/src/index.ts b/packages/client/runtime/src/index.ts index b0d0f0a7c8..c1ea85d1e5 100644 --- a/packages/client/runtime/src/index.ts +++ b/packages/client/runtime/src/index.ts @@ -1,11 +1,4 @@ -/** - * Runtime plugin, node half. The implementation lives entirely in the client - * half (src/client/ — SlotsService, SessionsService + object layer, and the - * shell-held ClientLoader under ./loader); consumers import the /client or - * /loader subpaths. The empty apply exists so the plugin appears in the host - * Loader (lifecycle governance + dshClient discovery). Contract: - * api-contracts v3 section 4. - */ +/** Host loader entry for the browser runtime exported from `./client` and `./loader`. */ /** Host plugin body — no host-side behavior for the runtime plugin. */ export function apply(_ctx: unknown): void {} diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 8c850426bd..2b469ca055 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -187,8 +187,7 @@ describe('cell (render-layer session kit)', () => { const cell = b.svc.cell('s1') expect(cell).toBeDefined() expect(cell?.sessionId).toBe('s1') - // Bare-source form (store migration): the cell carries the Session - // observable itself; hook binding happens in the React machinery. + // Hook binding happens in React; the cell carries the observable itself. expect(cell?.session).toBe(b.svc.manager.get(sid('s1'))) expect(b.svc.cell('s1')).toBe(cell) expect(b.svc.cell('ghost')).toBeUndefined() diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 372eb36c80..536859b59a 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,14 +1,4 @@ -/** - * Client plugin body: register the conversation/details slot occupants and - * the no-session empty state, contribute the chat entry into the - * 'conversation.view' ring that the conversation registration declares, then - * mount the conversation service (class plugin) and the bash toolview sample. - * Assembly only — components receive everything through props: the framework - * standard kit and store faces arrive automatically from the declarations - * below; the inject factories contribute the plain-data-and-callbacks - * business face (design §5). Tool rows are ordinary keyed-slot registrations - * into 'conversation.chat.toolview' — no dedicated registry exists. - */ +/** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -25,7 +15,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { EmptyState } from './skeleton/EmptyState.tsx' -/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ +/** Services required by the conversation plugin. */ export const inject = ['slots', 'layout', 'sessions'] /** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */ @@ -37,24 +27,17 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat return conversation } -/** - * Client plugin body. - * @param ctx - client root context. +/** Mounts the conversation plugin. + * @param ctx - Client root context. */ export function apply(ctx: Context): void { const sessions = ctx.sessions const layout = ctx.layout const slots = ctx.slots - // Shared store handle, constructed here so its identity lives and dies with - // this fiber (a module-level handle would be a de-facto singleton). The - // conversation, chat-view, and details registrations all declare it; same - // scope key = same instance, so chat-view selection writes and details - // reads meet in one store. + // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() - // Tab projection over the view ring's ledger (list entries carry id/order/ - // label as registration options; the ledger keeps them order-sorted). const viewTabs = (): ViewTab[] => { const tabs: ViewTab[] = [] for (const entry of slots.entries('conversation.view')) { diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index d7211f2f91..50dead9529 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -1,9 +1,4 @@ -// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284 -// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow -// (part of the chat view body — the chrome attachment mechanism retired with -// the view ring). Duration has no data source in P-I (ledger). Subscribes to -// `nodes` only: chunk batches never swap that reference, so the row renders -// zero times during streaming (the RFC performance model's acceptance row). +// Settled-node identity prevents stream-delta updates from rerendering this row. import { memo, useMemo } from 'react' import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index ffbc13ff59..9745c4518b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,15 +1,4 @@ -/** - * Slot-ring contract for the conversation package: the 'conversation.view' - * slot this package declares (the view ring — one list entry per conversation - * view tab), the chat view's per-tool row hole ('conversation.chat.toolview', - * keyed on the wire tool name), and the composed props shapes its registrants - * mount into the layout-owned slots (conversation / details / - * conversation.empty) plus its own slots. Terminal slot design (§3): full - * component props are the automatic shares — PropsRuntime (framework - * standard kit) & PropsRenderSlots (declared children) & PropsStore - * (declared store's read/write faces) & the injected business face declared - * here. - */ +/** Conversation slot declarations and their composed component props. */ import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' @@ -93,15 +82,9 @@ export type ConvViewProps = PropsRuntime<'conversation.view'> /** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */ export type ChatStore = ReturnType -/** - * Injected share of the conversation slot: plain data and callbacks only - * (design §5 — hooks are framework-made). The store lines that used to ride - * here live in the declared {@link ChatStore}; ancestry derives from the - * standard useSessions hook in-component; views render through the declared - * 'conversation.view' child slot, with this face projecting the tab strip. - */ +/** Business callbacks injected into the conversation slot. */ export interface ConversationInjected { - /** View tab read face (uSES triple over the 'conversation.view' slot ledger). */ + /** Views projected from the `conversation.view` slot ledger. */ views: { list(): readonly ViewTab[] subscribe(fn: () => void): () => void @@ -111,7 +94,6 @@ export interface ConversationInjected { send(text: string, mode: 'queue' | 'steer'): void /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ stop(): void - /** Navigate to another session (breadcrumb ancestors). */ open(id: SessionId): void } @@ -123,7 +105,6 @@ export interface ConversationInjected { * with zero owner changes. */ export interface ComposerChainProps { - /** The session's live pending waits, in arrival order (snapshot reference). */ interactions: readonly PendingInteraction[] } @@ -139,7 +120,6 @@ export type ConversationSlotProps = export interface ChatViewInjected { /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ openDetails(target: SelectionTarget): void - /** Pull one older history page. */ loadOlder(): void } diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index da573f007a..9ef9515f19 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -1,14 +1,4 @@ -/** - * Shared conversation contract primitives: the view tab projection (slot - * entries in 'conversation.view' surface as tabs), the chat store state - * shared through the declared store, and the selection primitives every - * domain consumes. Shared face between the skeleton domain (tab strip + - * view outlet) and the chat domain; domain implementation files import this, - * never each other. The view ring itself IS the 'conversation.view' slot - * (contract in slots.ts) — the package-local view registry is retired, and - * so is the hand-threaded translate channel (framework-level per-slot i18n - * injection is the planned replacement). - */ +/** Shared conversation view, selection, and store-state contracts. */ /** Tool call identity as carried on the wire (branded upstream in connection). */ export type CallId = string @@ -23,11 +13,8 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C export interface ViewTab { id: string; label: string } /** - * Chat store state (slot terminal design §4): the per-session store shared by - * the conversation, chat-view, and details registrations. `createChatStore` - * implements this shape. `view` may carry a stale persisted id after a view - * plugin unloads — the slot ledger is the runtime validator (unknown ids fall - * back to the first registered view). + * Per-session state shared by conversation, chat-view, and details slots. + * Unknown persisted view ids fall back to the first registered view. */ export interface ChatStoreState { /** Details-linkage channel (conversation writes, details reads). */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 8cfebac81c..d55ba1bd1e 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,12 +1,7 @@ /** - * Conversation domain plugin, browser half: skeleton (header/tabs/composer), - * the 'conversation.view' slot ring (chat entry here; other plugins - * contribute view tabs through ctx.slots), the chat view's keyed - * 'conversation.chat.toolview' row hole, scope-addressed ConversationService, - * minimal details panel. Contract: api-contracts v3 section 7. Thin shell: - * type surfaces live in contract/, assembly in apply.ts; the implementation - * domains (skeleton/chat) never import each other — contract/ is their only - * shared face. + * Browser conversation plugin. `contract/` is the shared type boundary + * between the independently implemented skeleton and chat domains; `apply.ts` + * owns their slot assembly. */ import type { ConversationService } from './service.ts' diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 0c6b9632bb..68fb7c4c31 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -1,17 +1,11 @@ /** - * ConversationService implementation: scope-addressed send/cancel and the - * empty-state startSession chain. Contract: api-contracts v3 section 7. - * Selection/draft state moved to the declared chat store (slot terminal - * design §4); the view registry moved to the 'conversation.view' slot (slot - * ledger owns registration, ordering, and disposal) — what remains is the - * send/stop orchestration face. + * Scope-addressed conversation send, cancel, and empty-state session startup. * * Scope addressing rides the cordis Service tracker: property access through * `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods - * read the session tag with scopeOf (same mechanism as the host tool - * registry). Mutable state lives in plain objects reached by one property - * read — field assignment through the tracker's shadow proxy is off-limits, - * as are `#` hard-private fields. + * read the session tag with `scopeOf`. Mutable state must remain reachable + * through one property read; assignment through the tracker proxy and `#` + * private fields bypass that rebinding. */ import { Service } from 'cordis' import type { Context } from 'cordis' diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 04d1dd867d..a27a2662b7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -1,12 +1,5 @@ -// InputBar: the one composer input (figma Input_Bottom). The same component -// serves the empty state (variant='hero': centered launch card) and the -// resident composer (variant='composer') — the empty→content transition is a -// position move of this component, never a swap (layout ruling). Running -// LOCKS the input: textarea disabled with the draft visible, stop is the only -// action; the turn ending re-enables and refocuses. -// -// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now — -// local native { setPathDraft(e.target.value) }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - confirmPath() - } - }} - /> - - - - - - )} - > - { setWorkspaceName(e.target.value) }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - confirmCreate() - } - }} - /> - {modalError !== null &&
{modalError}
} -
- + { sendSession() }} + onAdd={() => { setPickerOpen(true) }} + /> ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 7161a31931..d7338f8f2e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -19,18 +19,27 @@ padding: 0; } -.error { +.error, +.status { width: 100%; max-width: 800px; margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); font-size: 12px; line-height: 18px; } +.status { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.error { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); +} + .card { display: flex; flex-direction: column; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index a27a2662b7..e65e08ec53 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -9,7 +9,7 @@ import css from './InputBar.module.css' /** Prompt failure surface (mirrors the session snapshot's promptError shape). */ export interface InputBarError { - op: 'send' | 'stop' + op: 'workspace' | 'session' | 'send' | 'stop' message: string } @@ -18,12 +18,17 @@ export interface InputBarProps { running: boolean disabled: boolean error: InputBarError | null + /** Observable async phase for browser fixtures and assistive technology. */ + status?: string + /** Hero = empty-state centered card; composer = resident bottom bar. */ variant: 'hero' | 'composer' placeholder?: string accessory?: ReactNode onDraftChange: (text: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void + onAdd?: () => void + addLabel?: string } interface SelectOption { @@ -47,7 +52,8 @@ const MODEL_OPTIONS: readonly SelectOption[] = [ ] export function InputBar({ - draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop, + draft, running, disabled, error, status, variant, placeholder, accessory, + onDraftChange, onSend, onStop, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const empty = draft.trim() === '' const inputRef = useRef(null) @@ -98,7 +104,7 @@ export function InputBar({ inputRef.current?.focus() } - const primaryLabel = running ? '停止' : '发送' + const primaryLabel = running ? 'Stop generating' : 'Send message' const onPrimary = (): void => { if (running) { onStop() @@ -129,11 +135,8 @@ export function InputBar({ return (
- {error !== null && ( -
- {error.op === 'stop' ? '停止失败' : '发送失败'}:{error.message} -
- )} + {status !== undefined &&
{status}
} + {error !== null &&
{error.message}
}
{accessory !== undefined &&
{accessory}
} {/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper @@ -145,7 +148,7 @@ export function InputBar({ className={css.input} value={draft} disabled={locked} - placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息,Enter 发送,Shift+Enter 换行')} + placeholder={placeholder ?? (disabled ? 'Session unavailable' : running ? 'Generating a response…' : 'Message the agent')} rows={2} onChange={(e) => onDraftChange(e.target.value)} onKeyDown={onKeyDown} @@ -159,10 +162,11 @@ export function InputBar({ @@ -177,7 +181,7 @@ export function InputBar({ type="button" className={clsx(css.primary, running && css.stopping)} aria-label={primaryLabel} - title={running ? '停止本轮' : '发送(Enter)'} + title={primaryLabel} disabled={!running && (empty || disabled)} onMouseDown={keepFocus} onClick={onPrimary} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 1447b6486a..21fe70499e 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -3,8 +3,8 @@ // shape: the conversation surface (views triple, send choreography incl. // optimistic clear + failure restore THROUGH the declared store actions, // openDetails = select action + layout orchestration, sessions.open -// navigation), the injectless-but-closeDetails details surface, and the -// one-callback empty surface. Complements chat-apply.spec.tsx (registration) +// navigation), and the closeDetails details surface. Complements +// chat-apply.spec.tsx (registration) // and selection-survival.spec.ts (store axis). History opening is NOT an // inject concern anymore — the runtime sessions service opens on watch // (sessions-service.spec.ts owns that behavior). @@ -14,9 +14,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { + SessionId, SessionListState, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react' -import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -53,10 +55,14 @@ async function bench() { ids: [ROOT], byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } }, current: ROOT, - } as SessionListState) + intent: undefined, + phase: 'ready', + }) const sessionFake = { open: vi.fn(() => Promise.resolve()), loadOlder: vi.fn(() => Promise.resolve()), + updatePendingPrompt: vi.fn(), + retryPendingPrompt: vi.fn(), prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>( () => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>( @@ -73,15 +79,24 @@ async function bench() { } const sessionsFake = { list: listStore, - manager: { get: () => sessionFake }, + binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), scope: (id: SessionId) => mint(id), cell: () => undefined, scopeOf, - create: vi.fn(() => Promise.resolve(ROOT)), - createWorkspace: vi.fn(() => Promise.resolve(ROOT)), open: vi.fn(), + updateIntent: vi.fn(), } ctx.provide('sessions', sessionsFake) + const workspaceStore = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + const workspacesFake = { + list: workspaceStore, + startSession: vi.fn(), + sendSession: vi.fn(), + } + ctx.provide('workspaces', workspacesFake) const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() } ctx.provide('layout', layoutFake) ctx.provide('i18n', { bind: () => (key: string) => key }) @@ -124,19 +139,25 @@ async function bench() { id, instance.actions) return { instance, injected } } - return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint } + const emptySurface = () => { + const entry = entryOf('conversation.empty') + return (entry.inject as unknown as () => EmptyStateInjected)() + } + return { + ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, emptySurface, + sessionFake, sessionsFake, workspacesFake, layoutFake, mint, + } } describe('conversation slot inject surface', () => { - it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => { + it('assembles the thin surface side-effect-free', async () => { const b = await bench() const { injected } = b.conversationSurface(ROOT) // Assembly has no session side effects: opening the event window belongs // to the runtime watch path, not the inject factory. expect(b.sessionFake.open).not.toHaveBeenCalled() expect(injected.views.list().map(v => v.id)).toEqual(['chat']) - injected.open(ROOT) - expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) + const chatView = b.chatViewSurface(ROOT) chatView.injected.loadOlder() expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) @@ -202,6 +223,17 @@ describe('conversation slot inject surface', () => { expect(conv.instance).toBe(instance) }) + it('routes navigation through SessionsService and the retained prompt through the scoped Session', async () => { + const b = await bench() + const { injected } = b.conversationSurface(ROOT) + injected.open(ROOT) + injected.updateSessionPrompt('revised') + injected.retrySessionPrompt() + expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) + expect(b.sessionFake.updatePendingPrompt).toHaveBeenCalledWith('revised') + expect(b.sessionFake.retryPendingPrompt).toHaveBeenCalledOnce() + }) + it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => { const b = await bench() const { injected } = b.conversationSurface(ROOT) @@ -225,7 +257,7 @@ describe('conversation slot inject surface', () => { }) }) -describe('details and empty inject surfaces', () => { +describe('details inject surface', () => { it('details injects the one layout callback; selection rides the shared store instead', async () => { const b = await bench() const entry = b.entryOf('details') @@ -239,29 +271,18 @@ describe('details and empty inject surfaces', () => { expect(details).toBe(conv) }) - it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => { + it('empty state injects the runtime intent actions and remains storeless', async () => { const b = await bench() const entry = b.entryOf('conversation.empty') expect(entry.store).toBeUndefined() - const injected = (entry.inject as unknown as () => EmptyStateInjected)() - expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession']) - await injected.startSession({ text: 'go', mode: 'queue' }) - expect(b.sessionsFake.create).toHaveBeenCalled() - expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) - expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue') - b.sessionsFake.open.mockClear() - await injected.createWorkspaceSession('Fresh') - expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh') - expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) - }) - - it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => { - const b = await bench() - const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)() - // Tear the service's own fiber (registry keyed by the class): the slot - // entries survive, so the gesture-time read hits the loud branch. - b.ctx.registry.delete(ConversationService) - await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() }) - expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/) + const injected = b.emptySurface() + injected.startSession(undefined, 'fresh') + injected.startSession('workspace-1' as never, 'retargeted') + injected.updateSessionPrompt('typed') + injected.sendSession() + expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(1, undefined, 'fresh') + expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(2, 'workspace-1', 'retargeted') + expect(b.sessionsFake.updateIntent).toHaveBeenCalledWith('typed') + expect(b.workspacesFake.sendSession).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index adb9271c71..dcfe2d7de7 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -30,16 +30,23 @@ async function bench() { [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 }, }, current: undefined, + intent: undefined, + phase: 'ready', } as SessionListState) const sessionsFake = { list: listStore, - manager: { get: vi.fn() }, + binding: vi.fn(), scope: () => undefined, cell: () => undefined, create: vi.fn(), open: vi.fn(), + updateIntent: vi.fn(), } ctx.provide('sessions', sessionsFake) + ctx.provide('workspaces', { + startSession: vi.fn(), + sendSession: vi.fn(), + }) ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) ctx.provide('i18n', { bind: () => (key: string) => key }) @@ -84,7 +91,7 @@ describe('apply wiring', () => { expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' }) }) - it('occupies the three slots + the ring; session entries share one store handle, empty declares none', async () => { + it('occupies the three slots + the ring; session entries share one store handle, empty injects runtime actions', async () => { const b = await bench() await b.fiber.await() const conversation = renderEntryOf(b.slots, 'conversation') diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 4d3383b2d1..d73f8243ae 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -27,8 +27,8 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], - pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, + hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null, } } @@ -127,6 +127,8 @@ describe('bash sample row', () => { [CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 }, }, current: undefined, + intent: undefined, + phase: 'ready', } as SessionListState) } diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 7f66d1deb8..bfae743cff 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, SessionId, SessionListState, ToolResultNode, + ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' @@ -40,8 +40,8 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot { return { sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], - pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, + hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null, } as ConversationSnapshot } @@ -65,9 +65,11 @@ async function bench(nodes: ToolResultNode[]) { const session = createSnapshotStore(snapshotWith(nodes)) const list = createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } }, current: SID, - } as SessionListState) + intent: undefined, + phase: 'ready', + }) // Identity-stable cell: the renderer caches hooks per source and inject // results per cell, both by object identity. const cell = { sessionId: SID, session } @@ -75,11 +77,20 @@ async function bench(nodes: ToolResultNode[]) { const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } ctx.provide('sessions', { list, - manager: { get: () => ({ loadOlder: vi.fn() }) }, + binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }), scope: () => ({ get: () => scoped }), cell: (id: string) => (id === SID ? cell : undefined), create: vi.fn(), open: vi.fn(), + updateIntent: vi.fn(), + }) + ctx.provide('workspaces', { + list: createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }), + startSession: vi.fn(), + sendSession: vi.fn(), }) ctx.provide('layout', layout) ctx.provide('i18n', { bind: () => (key: string) => key }) @@ -182,12 +193,23 @@ describe('registrant load-order seam', () => { await slotsFiber.await() const slots = ctx.get('slots') as SlotsService ctx.provide('sessions', { - list: createSnapshotStore({ ids: [], byId: {}, current: undefined } as SessionListState), - manager: { get: vi.fn() }, + list: createSnapshotStore({ + ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready', + }), + binding: () => undefined, scope: () => undefined, cell: () => undefined, create: vi.fn(), open: vi.fn(), + updateIntent: vi.fn(), + }) + ctx.provide('workspaces', { + list: createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }), + startSession: vi.fn(), + sendSession: vi.fn(), }) ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) ctx.provide('i18n', { bind: () => (key: string) => key }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index aeda8ef6d2..bb55fcac77 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { - AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, + AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' @@ -29,8 +29,8 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], - pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, + hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null, } } @@ -72,7 +72,15 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined } as SessionListState) + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} + +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) return bindSnapshotSelector(store) } @@ -95,6 +103,7 @@ function makeHarness(init?: Partial) { sessionId: SID, useSession: bindSnapshotSelector(source), useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(chat), actions: chat.actions, renderSlot, diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 66ae3e802c..451c860972 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -87,8 +87,10 @@ describe('tails', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], - byId: { [sid]: { id: sid, title: 'r', running: false, updatedAt: 0 } }, + byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 } }, current: undefined, + intent: undefined, + phase: 'ready', } as SessionListState) const props = { callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(), diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index aaf1142266..7994930b75 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -5,7 +5,7 @@ import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' -import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { createChatStore } from '../src/client/stores.ts' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' @@ -19,8 +19,8 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], - pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, + hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null, } as ConversationSnapshot } @@ -65,12 +65,17 @@ describe('render branch tails', () => { const chat = createChatStore().create() chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined } as SessionListState) + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + const emptyWorkspaces = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) const view = render( snap, subscribe: () => () => {} }) as unknown as UseSession} useSessions={bindSnapshotSelector(emptyList)} + useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} diff --git a/packages/client/ui-conversation/tests/hook.ts b/packages/client/ui-conversation/tests/hook.ts deleted file mode 100644 index ef01792747..0000000000 --- a/packages/client/ui-conversation/tests/hook.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Test-local selector binding through the production uSES implementation. - * Runtime remains React-free, so specs bind observable sources here. - */ -import { bindSnapshotSelector } from '../../web-react/src/bind.ts' - -/** Minimal observable source (engine stores and scripted fakes both satisfy it). */ -export interface HookSource { - getSnapshot(): T - subscribe(fn: () => void): () => void -} - -/** - * Bind a selector hook over a snapshot source. - * @param src - the source. - * @returns a SnapshotSelectorHook-shaped hook. - */ -export function hookOf(src: HookSource) { - return bindSnapshotSelector(src) -} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 6bf58f7d59..d6367ad12a 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -19,9 +19,9 @@ function setup(over?: Partial) { } const view = render() const textarea = view.container.querySelector('textarea')! - // aria-label (not role name): title also contains 发送/停止 and would double-match. + // aria-label (not role name): title carries the same label and would double-match. const button = view.container.querySelector( - `button[aria-label="${over?.running === true ? '停止' : '发送'}"]`, + `button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`, )! return { view, textarea, button, props } } @@ -80,7 +80,7 @@ describe('running lock and primary button', () => { it('running locks the textarea and turns the primary into stop', () => { const { textarea, button, props } = setup({ running: true }) expect(textarea.disabled).toBe(true) - expect(button.getAttribute('aria-label')).toBe('停止') + expect(button.getAttribute('aria-label')).toBe('Stop generating') fireEvent.click(button) expect(props.onStop).toHaveBeenCalledTimes(1) expect(props.onSend).not.toHaveBeenCalled() @@ -100,30 +100,30 @@ describe('running lock and primary button', () => { const textarea = view.container.querySelector('textarea')! expect(document.activeElement).toBe(textarea) textarea.blur() - fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!) + fireEvent.mouseDown(view.container.querySelector('button[aria-label="Send message"]')!) expect(document.activeElement).toBe(textarea) }) it('disabled state shows the unavailable placeholder; typing forwards drafts', () => { const { textarea } = setup({ disabled: true, draft: '' }) - expect(textarea.placeholder).toBe('会话不可用') + expect(textarea.placeholder).toBe('Session unavailable') const live = setup({ draft: '' }) - expect(live.textarea.placeholder).toContain('Enter 发送') + expect(live.textarea.placeholder).toBe('Message the agent') fireEvent.change(live.textarea, { target: { value: 'typed' } }) expect(live.props.onDraftChange).toHaveBeenCalledWith('typed') const runningPh = setup({ running: true, draft: '' }) - expect(runningPh.textarea.placeholder).toContain('停止') - const custom = setup({ placeholder: '自定义' }) - expect(custom.textarea.placeholder).toBe('自定义') + expect(runningPh.textarea.placeholder).toBe('Generating a response…') + const custom = setup({ placeholder: 'Custom placeholder' }) + expect(custom.textarea.placeholder).toBe('Custom placeholder') }) }) describe('error strip and variants', () => { it('renders send and stop failure copy', () => { const send = setup({ error: { op: 'send', message: 'boom' } }) - expect(send.view.getByText(/发送失败:boom/)).toBeTruthy() + expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom') const stop = setup({ error: { op: 'stop', message: 'halt' } }) - expect(stop.view.getByText(/停止失败:halt/)).toBeTruthy() + expect(stop.view.container.querySelector('[role="alert"]')?.textContent).toBe('halt') }) it('hero variant adds the hero class and accessory row renders', () => { @@ -136,7 +136,7 @@ describe('error strip and variants', () => { describe('placeholder chrome', () => { it('renders attach / Plan / Read-only / model controls', () => { const { view } = setup() - expect(view.getByLabelText('添加')).toBeTruthy() + expect(view.getByLabelText('Add attachment')).toBeTruthy() expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan') expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly') expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high') @@ -162,7 +162,7 @@ describe('placeholder chrome', () => { it('running locks the chrome selects and attach control', () => { const { view } = setup({ running: true }) - expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true) expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true) }) diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index 866b34e2a6..6210b6e492 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -5,27 +5,31 @@ */ import { Context } from 'cordis' import { beforeEach, describe, expect, it } from 'vitest' -import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' import { createChatStore } from '../src/client/stores.ts' -// Use the runtime's programmable fake to drive the real session service. -import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' - const sid = (s: string): SessionId => s as SessionId interface Bench { - ctx: Context - api: FakeApiClient - sessions: SessionsService slots: SlotsService chat: ReturnType } function bench(): Bench { const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionsService(ctx, api) + ctx.provide('sessions', { + list: createSnapshotStore({ + ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready', + }), + cell: () => undefined, + }) + ctx.provide('workspaces', { + list: createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }), + }) // Service self-registers as ctx 'slots' (cordis Service constructor). const slots = new SlotsService(ctx) const chat = createChatStore() @@ -42,22 +46,7 @@ function bench(): Bench { }, (_p: { renderSlot?: unknown }) => null) slots.register({ name: 'conversation', store: chat }, () => null) slots.register({ name: 'details', store: chat }, () => null) - return { ctx, api, sessions, slots, chat } -} - -async function flush(): Promise { - // Manager notifier + store batching are microtask-based. - await Promise.resolve() - await Promise.resolve() -} - -function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[]): void { - b.api.onList = () => Promise.resolve(ok({ - items: rows.map(r => ({ - sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, - ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), - })), - }) as never) + return { slots, chat } } /** Resolve the store instance the renderer would hand a slot's component for a session. */ @@ -87,11 +76,8 @@ beforeEach(() => { }) describe('selection survives on the store seat', () => { - it('one session, two slots: conversation writes, details reads the SAME instance', async () => { + it('one session, two slots: conversation writes, details reads the SAME instance', () => { const b = bench() - feed(b, [{ id: 's1' }]) - await b.sessions.manager.refreshList() - await flush() const conv = storeFor(b, 'conversation', sid('s1')) const details = storeFor(b, 'details', sid('s1')) @@ -101,11 +87,8 @@ describe('selection survives on the store seat', () => { expect(details).toBe(conv) }) - it('sessions are isolated: s2 selection never bleeds into s1', async () => { + it('sessions are isolated: s2 selection never bleeds into s1', () => { const b = bench() - feed(b, [{ id: 's1' }, { id: 's2' }]) - await b.sessions.manager.refreshList() - await flush() const one = storeFor(b, 'conversation', sid('s1')) const two = storeFor(b, 'conversation', sid('s2')) @@ -116,25 +99,17 @@ describe('selection survives on the store seat', () => { expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) }) - it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => { + it('a list-projection update keeps instance identity and the selection value', () => { const b = bench() - // First-send shape: client-side create inserts the row without cwd (title = bare id). - b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') })) - const id = await b.sessions.create({}) - await flush() - expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' }) - expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined() + const id = sid('s1') + const projection = createSnapshotStore({ displayTitle: 's1' }) const store = storeFor(b, 'conversation', id) store.actions.select({ turnSeq: 3, callId: 'c1' }) store.actions.setDraft('half-typed') - // The late list refresh lands (host knows the cwd → better fallback label). - feed(b, [{ id: 's1', cwd: '/w/proj-a' }]) - await b.sessions.manager.refreshList() - await flush() - expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' }) - expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined() + projection.set({ displayTitle: 'proj-a' }) + expect(projection.getSnapshot().displayTitle).toBe('proj-a') const after = storeFor(b, 'conversation', id) expect(after).toBe(store) @@ -142,32 +117,20 @@ describe('selection survives on the store seat', () => { expect(after.store.getSnapshot().draft).toBe('half-typed') }) - it('session death buries the instance and its persisted draft', async () => { + it('session death buries the instance and its persisted draft', () => { const b = bench() - feed(b, [{ id: 's1' }, { id: 's2' }]) - await b.sessions.manager.refreshList() - await flush() - // Mint the scope (store prune rides the scope-teardown axis: no scope, - // no teardown — the real page always resolves the binding to render). - b.sessions.binding(sid('s1')) const doomed = storeFor(b, 'conversation', sid('s1')) doomed.actions.setDraft('to be buried') doomed.actions.select({ turnSeq: 1 }) expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull() - // Watch elsewhere so s1's scope teardown is not deferred, then remove it. - b.sessions.binding(sid('s2')) - feed(b, [{ id: 's2' }]) - await b.sessions.manager.refreshList() - await flush() + // SessionsService calls this public slot lifecycle seam when the scope dies. + b.slots.pruneStoreScope(sid('s1')) // Persisted residue is gone with the session... expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull() // ...and a re-created same-id session starts from a FRESH instance. - feed(b, [{ id: 's1' }, { id: 's2' }]) - await b.sessions.manager.refreshList() - await flush() const reborn = storeFor(b, 'conversation', sid('s1')) expect(reborn).not.toBe(doomed) expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 2d3be81a4d..0b107c6dbd 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -1,154 +1,70 @@ // @vitest-environment jsdom -/** - * ConversationService orchestration half after the store-seat slimming: - * scope-addressed send/cancel (result folding, root throw), the startSession - * chain (create → sessions.open → scoped send), and the service-unavailable - * loud failures. Selection/draft state left this service for the declared - * chat store (chat-store.spec.ts / selection-survival.spec.ts); the view - * registry left for the 'conversation.view' slot (views-type-chain.spec.tsx). - */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' -const sid = (s: string): SessionId => s as SessionId - -/** Recover the module-private scope tag through the public seam (same probe as apply-inject.spec). */ +const sid = (id: string) => id as SessionId const SCOPE_TAG: symbol = (() => { - const recorded: (string | symbol)[] = [] - const spy = new Proxy(new Context(), { - get(target, prop, receiver): unknown { - recorded.push(prop) - return Reflect.get(target, prop, receiver) + const reads: (string | symbol)[] = [] + const proxy = new Proxy(new Context(), { + get(target, property, receiver): unknown { + reads.push(property) + return Reflect.get(target, property, receiver) }, }) - void scopeOf(spy) - const symbol = recorded.find((p): p is symbol => typeof p === 'symbol') - if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read') - return symbol + void scopeOf(proxy) + return reads.find((value): value is symbol => typeof value === 'symbol')! })() -interface SessionDouble { - prompt: ReturnType - cancel: ReturnType -} - -async function bench(opts?: { sessions?: boolean }) { +async function bench(withSessions = true) { const ctx = new Context() - const sessionDoubles = new Map() - const scopes = new Map() - const mint = (id: SessionId): Context => { - let scoped = scopes.get(id) - if (scoped === undefined) { - const fiber = ctx.plugin(() => {}) - scoped = fiber.ctx.extend({ [SCOPE_TAG]: id }) - scopes.set(id, scoped) - } - return scoped - } - const createMock = vi.fn(() => Promise.resolve(sid('new-1'))) - const openMock = vi.fn() - const sessionsFake = { - manager: { - get: (id: SessionId) => { - let s = sessionDoubles.get(id) - if (s === undefined) { - s = { - prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), - cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), - } - sessionDoubles.set(id, s) - } - return s - }, - }, - create: createMock, - open: openMock, - scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)), + const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) + const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) + const loadOlder = vi.fn(() => Promise.resolve()) + const updatePendingPrompt = vi.fn() + const retryPendingPrompt = vi.fn() + const sessions = { + binding: (sessionId: SessionId) => ({ + sessionId, session: { prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt }, + }), scopeOf, } as unknown as SessionsService - if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake) - // Class-plugin mount — the same form apply.ts uses in production. - const fiber = ctx.plugin(ConversationService) - await fiber.await() - const svc = ctx.get('conversation') as ConversationService - const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService - return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, openMock } + if (withSessions) ctx.provide('sessions', sessions) + await ctx.plugin(ConversationService).await() + const root = ctx.get('conversation') as ConversationService + const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService + return { root, scoped, prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt } } -describe('send / cancel', () => { - it('sends one text block through the scoped session with the mode', async () => { +describe('ConversationService', () => { + it('routes ordinary and retained-prompt operations through the public Session binding', async () => { const b = await bench() - await b.scopedSvc(sid('s1')).send('hello', 'steer') - expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith( - [{ type: 'text', text: 'hello' }], 'steer') + await b.scoped.send('hello', 'steer') + await b.scoped.cancel() + await b.scoped.loadOlder() + b.scoped.updatePendingPrompt('revised') + b.scoped.retryPendingPrompt() + expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer') + expect(b.cancel).toHaveBeenCalledOnce() + expect(b.loadOlder).toHaveBeenCalledOnce() + expect(b.updatePendingPrompt).toHaveBeenCalledWith('revised') + expect(b.retryPendingPrompt).toHaveBeenCalledOnce() }) - it('folds business failure into a thrown error carrying code and message', async () => { + it('folds Session business failures into callback rejections', async () => { const b = await bench() - const s = b.scopedSvc(sid('s1')) - // Materialize the double first (manager.get is the lazy mint point). - b.sessionsFake.manager.get(sid('s1')) - const double = b.sessionDoubles.get(sid('s1'))! - double.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'busy' } }) - await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/) + b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never) + await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy') + b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never) + await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope') }) - it('cancel resolves on ok and throws the folded business error', async () => { + it('fails loudly from the root scope or without SessionsService', async () => { const b = await bench() - const s = b.scopedSvc(sid('s1')) - await s.cancel() - const double = b.sessionDoubles.get(sid('s1'))! - expect(double.cancel).toHaveBeenCalledTimes(1) - double.cancel.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'nope' } }) - await expect(s.cancel()).rejects.toThrow(/cancel failed: internal: nope/) - }) - - it('root-context send and cancel throw the addressing hint', async () => { - const b = await bench() - await expect(b.svc.send('x', 'queue')).rejects.toThrow(/requires a session scope/) - await expect(b.svc.cancel()).rejects.toThrow(/requires a session scope/) - }) -}) - -describe('startSession chain', () => { - it('creates, navigates through sessions.open, then sends through the new scope', async () => { - const b = await bench() - await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' }) - expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' }) - expect(b.openMock).toHaveBeenCalledWith(sid('new-1')) - expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith( - [{ type: 'text', text: 'first' }], 'queue') - }) - - it('omits cwd from create when not chosen', async () => { - const b = await bench() - await b.svc.startSession({ text: 't', mode: 'steer' }) - expect(b.createMock).toHaveBeenCalledWith({}) - }) - - it('fails loud when the created session resolves no scope', async () => { - const b = await bench() - ;(b.sessionsFake.create as ReturnType).mockResolvedValue(sid('ghost')) - await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/resolved no scope/) - }) -}) - -describe('service-unavailable loud failures', () => { - it('throws when sessions is missing', async () => { - const b = await bench({ sessions: false }) - await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/) - }) - - it('startSession fails loud when the new scope cannot resolve conversation', async () => { - const b = await bench() - // A scope minted outside the service tree: scoped.get('conversation') finds nothing. - const foreign = new Context() - const foreignScope = foreign.plugin(() => {}).ctx.extend({}) - ;(b.sessionsFake.scope as unknown) = () => foreignScope - await expect(b.svc.startSession({ text: 't', mode: 'queue' })) - .rejects.toThrow(/conversation service unavailable through the new scope/) + await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/) + const missing = await bench(false) + await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/) }) }) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx deleted file mode 100644 index 4eb70ea39f..0000000000 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ /dev/null @@ -1,318 +0,0 @@ -// @vitest-environment jsdom -// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx -// acceptance flows), four-share props form: breadcrumb ancestry derivation + -// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text -// result blocks / error-only results over the shared store, EmptyState -// failure surface and path-modal confirm with in-component cwd derivation. - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { hookOf } from './hook.ts' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' -import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' -import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' -// Export discipline: packages/client/AGENTS.md. -import { createChatStore } from '../src/client/stores.ts' -import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' -import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' -import { EmptyState } from '../src/client/skeleton/EmptyState.tsx' - -afterEach(cleanup) - -const SID = 's1' as SessionId -/** Fallback-only chain stub (no takeover registered in these benches). */ -const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] = - (_key, _owner, opts) => opts?.fallback ?? null - -function snapshotBase(): ConversationSnapshot { - return { - sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], - pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, - } as ConversationSnapshot -} - -function sessionSource(over?: Partial) { - const snap = { ...snapshotBase(), ...over } - return { - getSnapshot: () => snap, - subscribe: () => () => {}, - } -} - -/** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */ -function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) { - const store = createSnapshotStore({ - ids: rows.map(r => r.id as SessionId), - byId: Object.fromEntries(rows.map(r => [r.id, { - id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1, - ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), - ...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}), - }])), - current: undefined, - } as SessionListState) - return hookOf(store) -} - -describe('ConversationRoot branches', () => { - const chatTab: ViewTab = { id: 'chat', label: 'Chat' } - /** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */ - const stubRenderSlot = (() =>
) as unknown as ConversationRootProps['renderSlot'] - /** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */ - const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)} - - function rootProps(over?: { - rows?: { id: string; title: string; parentId?: string }[] - snapshot?: Partial - }) { - const open = vi.fn() - const chat = createChatStore().create() - const view = render( - } - useSessions={listHook(over?.rows ?? [])} - useStore={hookOf(chat)} - actions={chat.actions} - renderSlot={stubRenderSlot} - renderSlotChain={fallbackRenderSlotChain} - SessionProvider={SessionProviderStub} - views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }} - send={vi.fn()} - stop={vi.fn()} - open={open} - />, - ) - return { view, open, chat } - } - - it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => { - const { view, open } = rootProps({ - rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }], - }) - expect(view.getByText('Workspace')).toBeTruthy() - expect(view.getByText('/')).toBeTruthy() - fireEvent.click(view.getByText('Workspace')) - expect(open).toHaveBeenCalledWith('root-1' as SessionId) - // The last crumb is the current session: disabled, no navigation. - fireEvent.click(view.getByText('Current')) - expect(open).toHaveBeenCalledTimes(1) - }) - - it('a broken parent link stops the ancestry walk at the known chain', () => { - const { view } = rootProps({ - rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }], - }) - // The walk keeps s1 itself and stops where the parent is unknown. - expect(view.getByText('Orphan')).toBeTruthy() - }) - - it('falls back to the raw session id without ancestry and counts user turns', () => { - const { view } = rootProps({ - snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] }, - }) - expect(view.getByText(SID)).toBeTruthy() - expect(view.getByText(/1 turns/)).toBeTruthy() - }) - - it('surfaces promptError through the composer error strip', () => { - const { view } = rootProps({ - snapshot: { promptError: { op: 'stop', error: { message: 'halt', code: 'internal' } } as never }, - }) - expect(view.getByText(/停止失败:halt(internal)/)).toBeTruthy() - }) - - it('an unknown stored view id falls back to the first registered view', () => { - const { chat } = rootProps({}) - cleanup() - chat.actions.setView('gone') - const view = render( - } - useSessions={listHook([])} - useStore={hookOf(chat)} - actions={chat.actions} - renderSlot={stubRenderSlot} - renderSlotChain={fallbackRenderSlotChain} - SessionProvider={SessionProviderStub} - views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }} - send={vi.fn()} - stop={vi.fn()} - open={vi.fn()} - />, - ) - expect(view.getByTestId('view-body')).toBeTruthy() - }) -}) - -describe('DetailsPanel branches', () => { - function panel(selection: SelectionTarget | null, snapshot?: Partial) { - const chat = createChatStore().create() - if (selection !== null) chat.actions.select(selection) - return render( - } - useSessions={listHook([])} - useStore={hookOf(chat)} - actions={chat.actions} - closeDetails={vi.fn()} - />, - ) - } - - it('shows non-JSON args verbatim (streaming fragment path)', () => { - const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, { - runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }], - }) - expect(view.getByText('{"cmd": tru')).toBeTruthy() - }) - - it('a selection without callId renders the empty hint (selector null arm)', () => { - const view = panel({ turnSeq: 2 }) - expect(view.getByText(/点击消息流中的工具行查看详情/)).toBeTruthy() - }) - - it('snapshot updates re-run the material selector through the shallow equality arm', () => { - let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot - const subs = new Set<() => void>() - const source = { - getSnapshot: () => snap, - subscribe: (fn: () => void) => { - subs.add(fn) - return () => subs.delete(fn) - }, - } - const chat = createChatStore().create() - chat.actions.select({ turnSeq: 1, callId: 'c9' }) - const view = render( - } - useSessions={listHook([])} - useStore={hookOf(chat)} - actions={chat.actions} - closeDetails={vi.fn()} - />, - ) - expect(view.getByText(/"a": 1/)).toBeTruthy() - // Top-level swap with identical material members: the eq arm short-circuits. - snap = { ...snap } - for (const fn of [...subs]) fn() - expect(view.getByText(/"a": 1/)).toBeTruthy() - }) - - it('windowless call material: no name/args fallback to callId, mixed node walk skips non-matches', () => { - // A tool-result whose call head fell outside the window (call === null), - // preceded by non-matching nodes so the walk exercises both filter arms. - const view = panel({ turnSeq: 1, callId: 'c8' }, { - nodes: [ - { kind: 'user', seq: 1, content: [], source: null } as never, - { kind: 'tool-result', seq: 2, callId: 'other', call: { name: 'x', argsRaw: '{}' }, content: [], isError: false, callView: null, resultView: null } as never, - { kind: 'tool-result', seq: 3, callId: 'c8', call: null, content: [], isError: false, callView: null, resultView: null } as never, - ], - }) - expect(view.getByText('c8')).toBeTruthy() - }) - - it('stringifies non-text result blocks and renders error-only results', () => { - const withBlocks = panel({ turnSeq: 1, callId: 'c2' }, { - nodes: [{ - kind: 'tool-result', seq: 3, callId: 'c2', call: { name: 'read', argsRaw: '{}' }, - content: [{ type: 'image', data: 'x' } as never], - isError: false, callView: null, resultView: null, - } as never], - }) - expect(withBlocks.getByText(/"type": "image"/)).toBeTruthy() - const errorOnly = panel({ turnSeq: 1, callId: 'c3' }, { - nodes: [{ - kind: 'tool-result', seq: 4, callId: 'c3', call: { name: 'bash', argsRaw: '{}' }, - content: [], isError: true, error: { name: 'ToolError', code: 'timeout' }, - callView: null, resultView: null, - } as never], - }) - expect(errorOnly.getByText(/ToolError: timeout/)).toBeTruthy() - }) -}) - -describe('EmptyState branches', () => { - const noopCreate = () => Promise.resolve() - - it('keeps the draft and surfaces a local error strip when startSession rejects', async () => { - const startSession = vi.fn(() => Promise.reject(new Error('create down'))) - const view = render( - , - ) - const textarea = view.container.querySelector('textarea')! - fireEvent.change(textarea, { target: { value: 'first task' } }) - fireEvent.keyDown(textarea, { key: 'Enter' }) - await waitFor(() => expect(view.getByText(/发送失败:create down/)).toBeTruthy()) - expect((textarea as HTMLTextAreaElement).value).toBe('first task') - }) - - it('non-Error rejection reasons stringify into the error strip', async () => { - const startSession = vi.fn(() => Promise.reject('plain-string')) - const view = render( - , - ) - const textarea = view.container.querySelector('textarea')! - fireEvent.change(textarea, { target: { value: 'go' } }) - fireEvent.keyDown(textarea, { key: 'Enter' }) - await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy()) - }) - - it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => { - const startSession = vi.fn(() => Promise.resolve()) - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: '项目目录' })) - expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) - .toEqual(['proj', 'New Workspace']) - fireEvent.click(view.getByRole('menuitem', { name: 'proj' })) - expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj') - fireEvent.click(view.getByRole('button', { name: '项目目录' })) - fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) - fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' })) - const custom = view.getByLabelText('Folder path') - fireEvent.change(custom, { target: { value: '/typed/dir' } }) - fireEvent.click(view.getByRole('button', { name: 'Open Folder' })) - const textarea = view.container.querySelector('textarea')! - fireEvent.change(textarea, { target: { value: 'task' } }) - fireEvent.keyDown(textarea, { key: 'Enter' }) - await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' })) - }) - - it('Create modal surfaces inject failures inline', async () => { - const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked'))) - const view = render( - Promise.resolve()} - createWorkspaceSession={createWorkspaceSession} - />, - ) - fireEvent.click(view.getByRole('button', { name: '项目目录' })) - fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) - fireEvent.click(view.getByRole('menuitem', { name: 'Create new' })) - fireEvent.click(view.getByRole('button', { name: 'Create' })) - await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked')) - }) -}) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a598803a25..fc596ec821 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -1,344 +1,203 @@ // @vitest-environment jsdom -/** - * Skeleton acceptance over the four-share props form: empty-state transition - * (same InputBar component in hero position, startSession submit, in-component - * cwd derivation), ConversationRoot view switching through the store's view - * field, DetailsPanel selection through the shared store. Components stay - * pure — the framework shares are stubbed (useSession/useSessions), the store - * share is a REAL createChatStore().create() instance (same construction path - * as production), injected callbacks are spies. - */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { UseSession } from '@deepseek-ai/dsh-client-web-react' -import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' -import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { EmptyStateProps } from '../src/client/skeleton/EmptyState.tsx' import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' -// Export discipline: packages/client/AGENTS.md. import { createChatStore } from '../src/client/stores.ts' import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx' -import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' import { EmptyState } from '../src/client/skeleton/EmptyState.tsx' -const sid = (s: string): SessionId => s as SessionId - afterEach(cleanup) -beforeEach(() => { - // jsdom normally provides localStorage; some host Node builds surface it as undefined. - globalThis.localStorage?.clear() +beforeEach(() => { localStorage.clear() }) + +const sid = (id: string) => id as SessionId +const wid = (id: string) => id as WorkspaceId +const SID = sid('s1') + +function workspace(id = 'w1'): WorkspaceView { + return { + workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + } +} + +type SessionIntent = NonNullable +type WorkspaceIntent = NonNullable + +const workspaceState = ( + items: readonly WorkspaceView[], workspaceIntent?: WorkspaceIntent, +): WorkspaceListState => ({ + items, intent: workspaceIntent, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, }) +const hook = (snapshot: T) => (selector: (state: T) => S): S => selector(snapshot) -/** Minimal conversation snapshot slice the skeleton reads. */ -interface FakeSnapshot { - nodes: readonly { - kind: string - seq?: number - time?: number - callId?: string - call?: { name: string; argsRaw: string } | null - callTime?: number | null - content?: readonly { type: string; text?: string }[] - isError?: boolean - callView?: null - resultView?: null - }[] - runningCalls: readonly { - callId: string - name: string - argsRaw: string - turn?: number - step?: number - time?: number - callView?: null - }[] - running: boolean - removed: boolean - promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null - pending: readonly PendingInteraction[] +function mountEmpty( + intent: SessionIntent, + items: readonly WorkspaceView[] = [], + localWorkspace?: WorkspaceIntent, +) { + const updateSessionPrompt = vi.fn() + const sendSession = vi.fn() + const startSession = vi.fn() + let pickerOwner: unknown + const sessionState: SessionListState = { + ids: [], byId: {}, current: intent.sessionId, intent, phase: 'ready', + } + const workspaceIntent = intent.target.kind === 'workspace-intent' + ? localWorkspace ?? { name: 'workspace', phase: 'ready' as const } + : undefined + const view = render( + { pickerOwner = owner; return null }) as EmptyStateProps['renderSlot']} + />, + ) + return { view, updateSessionPrompt, sendSession, startSession, pickerOwner: () => pickerOwner } } -function fakeSession(init: Partial = {}) { - const store = createSnapshotStore({ - nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init, - }) - return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } -} - -/** Sessions-list stub: the standard useSessions hook over a snapshot store. */ -function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) { - const store = createSnapshotStore({ - ids: rows.map(r => sid(r.id)), - byId: Object.fromEntries(rows.map(r => [r.id, { - id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1, - ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), - ...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}), - }])), - current: undefined, - } as SessionListState) - return { store, useSessions: bindSnapshotSelector(store) } -} - -/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */ -const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))} - describe('EmptyState', () => { - const noopCreate = () => Promise.resolve() - - it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => { - const { useSessions } = fakeSessions([ - { id: 'a', title: 'a', cwd: '/w/app' }, - { id: 'b', title: 'b', cwd: '/w/lib' }, - { id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes - ]) - let reject!: (e: Error) => void - const startSession = vi.fn(() => new Promise((_res, rej) => { reject = rej })) - render( - , - ) - - const trigger = screen.getByRole('button', { name: '项目目录' }) - fireEvent.click(trigger) - const menu = screen.getByRole('menu') - expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) - .toEqual(['app', 'lib', 'New Workspace']) - fireEvent.click(screen.getByRole('menuitem', { name: 'app' })) - const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands') - fireEvent.change(box, { target: { value: '造一个轮子' } }) - fireEvent.keyDown(box, { key: 'Enter' }) - expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' }) - - reject(new Error('后端拒收')) - expect(await screen.findByText(/后端拒收/)).toBeTruthy() - // Draft survives the failure for retry. - expect((box as HTMLTextAreaElement).value).toBe('造一个轮子') + it('reads the Workspace and Session intents from runtime projections', () => { + const b = mountEmpty({ + sessionId: sid('local-1'), target: { kind: 'workspace-intent' }, + prompt: 'draft', phase: 'ready', + }) + expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('workspace') + fireEvent.change(b.view.getByPlaceholderText('Describe what you want to build'), { target: { value: 'build it' } }) + expect(b.updateSessionPrompt).toHaveBeenCalledWith('build it') + fireEvent.click(b.view.getByRole('button', { name: 'Send message' })) + expect(b.sendSession).toHaveBeenCalledOnce() }) - it('Use a existing folder opens the path modal and Open Folder sets the chip', () => { - const { useSessions } = fakeSessions([]) - render( - Promise.resolve()} - createWorkspaceSession={noopCreate} - />, - ) - fireEvent.click(screen.getByRole('button', { name: '项目目录' })) - const newWs = screen.getByRole('menuitem', { name: 'New Workspace' }) - fireEvent.mouseEnter(newWs.parentElement as HTMLElement) - fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' })) - expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy() - const path = screen.getByLabelText('Folder path') as HTMLInputElement - fireEvent.change(path, { target: { value: '/tmp/fresh' } }) - fireEvent.click(screen.getByRole('button', { name: 'Open Folder' })) - expect(screen.queryByRole('dialog')).toBeNull() - expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh') + it('uses useWorkspaces for the selected label and preserves the prompt when retargeting', () => { + const first = workspace('first') + const b = mountEmpty({ + sessionId: sid('local-2'), target: { kind: 'workspace', workspaceId: first.workspaceId }, + prompt: 'keep me', phase: 'ready', + }, [first]) + expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('first') + fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' })) + const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void } + owner.onPick(wid('second')) + expect(b.startSession).toHaveBeenCalledWith(wid('second'), 'keep me') }) - it('Create new opens the modal and createWorkspaceSession succeeds', async () => { - const { useSessions } = fakeSessions([]) - const createWorkspaceSession = vi.fn(() => Promise.resolve()) - render( - Promise.resolve()} - createWorkspaceSession={createWorkspaceSession} - />, - ) - fireEvent.click(screen.getByRole('button', { name: '项目目录' })) - fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) - fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' })) - expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy() - const name = screen.getByLabelText('Workspace name') as HTMLInputElement - expect(name.value).toBe('New WorkSpace') - fireEvent.change(name, { target: { value: 'My Proj' } }) - fireEvent.keyDown(name, { key: 'Enter' }) - await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj')) - }) - - it('Create modal Cancel dismisses without calling createWorkspaceSession', () => { - const { useSessions } = fakeSessions([]) - const createWorkspaceSession = vi.fn(() => Promise.resolve()) - render( - Promise.resolve()} - createWorkspaceSession={createWorkspaceSession} - />, - ) - fireEvent.click(screen.getByRole('button', { name: '项目目录' })) - fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) - fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' })) - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) - expect(screen.queryByRole('dialog')).toBeNull() - expect(createWorkspaceSession).not.toHaveBeenCalled() + it('exposes materialization phase and failure text', () => { + const creating = mountEmpty({ + sessionId: sid('local-3'), target: { kind: 'workspace-intent' }, + prompt: 'x', phase: 'ready', + }, [], { name: 'workspace', phase: 'creating' }) + expect(creating.view.getByRole('status').textContent).toBe('Creating workspace…') + cleanup() + const workspaceFailed = mountEmpty({ + sessionId: sid('local-3'), target: { kind: 'workspace-intent' }, + prompt: 'x', phase: 'ready', + }, [], { name: 'workspace', phase: 'ready', error: 'offline' }) + expect(workspaceFailed.view.getByRole('alert').textContent).toBe('Workspace creation failed: offline') + cleanup() + const failed = mountEmpty({ + sessionId: sid('local-3'), target: { kind: 'workspace', workspaceId: wid('w1') }, + prompt: 'x', phase: 'ready', error: { step: 'session', message: 'offline' }, + }, [workspace()]) + expect(failed.view.getByRole('alert').textContent).toBe('Session creation failed: offline') }) }) -describe('ConversationRoot', () => { - function bench( - tabs: ViewTab[], activeView?: string, init: Partial = {}, - renderSlotChain?: ConversationRootProps['renderSlotChain'], - ) { - const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init }) - const { useSessions } = fakeSessions([ - { id: 'root', title: 'proj' }, - { id: 's1', title: 'child', parentId: 'root' }, - ]) - const chat = createChatStore().create() - if (activeView !== undefined) chat.actions.setView(activeView) - const send = vi.fn() - const stop = vi.fn() - const open = vi.fn() - // The renderSlot share as the outlet would bake it: renders a marker for - // the ring key carrying the active-id filter (a Mock cannot satisfy the - // generic method type directly — cast once at the prop seam). - const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => ( -
- )) - const ui = render( - opts?.fallback ?? null)} - SessionProvider={SessionProviderStub} - views={{ - list: () => tabs, - subscribe: () => () => {}, - version: () => 1, - }} - send={send} - stop={stop} - open={open} - />) - return { ui, chat, send, stop, open, renderSlot } +function conversationSnapshot( + composerPhase: ConversationSnapshot['composerPhase'], + pendingPrompt: ConversationSnapshot['pendingPrompt'] = null, +): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], + pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null, + hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null, } +} - const tab = (id: string, label: string): ViewTab => ({ id, label }) - - it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => { - const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')]) - expect(screen.getByText('proj')).toBeTruthy() - expect(screen.getByText('child')).toBeTruthy() - expect(screen.getByText(/2 turns/)).toBeTruthy() - expect(screen.getByTestId('view-chat')).toBeTruthy() - // Ancestor crumb navigates; current crumb is disabled. - fireEvent.click(screen.getByRole('button', { name: 'proj' })) - expect(open).toHaveBeenCalledWith('root') - expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true) +function mountConversation(pendingPrompt: ConversationSnapshot['pendingPrompt'] = null) { + const root = sid('root') + const sessions = createSnapshotStore({ + ids: [root, SID], + byId: { + [root]: { id: root, displayTitle: 'Root', running: false, updatedAt: 1 }, + [SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, updatedAt: 2 }, + }, + current: SID, + intent: undefined, + phase: 'ready', }) - - it('switches views through the store view field and falls back on unknown ids', () => { - const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')]) - fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - expect(chat.store.getSnapshot().view).toBe('trajectory') - expect(screen.getByTestId('view-trajectory')).toBeTruthy() - cleanup() - // A stale persisted id (its view plugin unloaded) falls to the first view. - bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view') - expect(screen.getByTestId('view-chat')).toBeTruthy() - }) - - it('renders the active view through the declared ring slot with the only filter', () => { - const { renderSlot } = bench([tab('chat', 'Chat')]) - // No owner share: views take everything from the standard kit (contract). - expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' }) - expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view') - }) - - it('hides the tab strip with a single view; composer writes the store draft and sends it', () => { - const { chat, send } = bench([tab('chat', 'Chat')]) - expect(screen.queryByRole('tablist')).toBeNull() - const box = screen.getByPlaceholderText(/输入消息/) - fireEvent.change(box, { target: { value: 'hi' } }) - // Typing goes through actions.setDraft into the shared store. - expect(chat.store.getSnapshot().draft).toBe('hi') - fireEvent.keyDown(box, { key: 'Enter' }) - expect(send).toHaveBeenCalledWith('hi', 'queue') - }) - - it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => { - const wait = new PendingWait('question', RpcId('rq'), sid('s1'), - { questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn()) - // A matching entry takes the composer over. - const renderSlotChain = vi.fn(() =>
question takeover
) as unknown as ConversationRootProps['renderSlotChain'] - bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain) - expect(screen.getByText('question takeover')).toBeTruthy() - expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull() - // The owner dispatches the raw pending list (chain currency); routing - // lives in entry selectors, not here. - expect(renderSlotChain).toHaveBeenCalledWith( - 'conversation.composer', - expect.objectContaining({ - interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]), - }), - expect.objectContaining({ fallback: expect.anything() }), - ) - cleanup() - // Zero registered entries (default all-decline stub): the fallback IS the - // default InputBar — behavior equals the pre-chain composer. - bench([tab('chat', 'Chat')], undefined, { pending: [wait] }) - expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy() - }) -}) - -describe('DetailsPanel', () => { - function benchDetails(snapshot: Partial, selection: SelectionTarget | null) { - const { useSession } = fakeSession(snapshot) - const { useSessions } = fakeSessions([]) - const chat = createChatStore().create() - if (selection !== null) chat.actions.select(selection) - const closeDetails = vi.fn() - render( - ) - return { closeDetails, chat } + const workspaces = createSnapshotStore(workspaceState([{ ...workspace('one'), sessionIds: [SID] }])) + const session = createSnapshotStore(conversationSnapshot( + pendingPrompt === null ? 'active' : 'blank', pendingPrompt, + )) + const chat = createChatStore().create() + chat.actions.setDraft('ordinary draft') + const send = vi.fn() + const stop = vi.fn() + const open = vi.fn() + const updateSessionPrompt = vi.fn() + const retrySessionPrompt = vi.fn() + const renderSlot = ((_key: string, _owner: object, opts?: { only?: string }) => ( +
+ )) as ConversationRootProps['renderSlot'] + const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain'] + const SessionProvider: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)} + const props: ConversationRootProps = { + sessionId: SID, + useSession: bindSnapshotSelector(session), + useSessions: bindSnapshotSelector(sessions), + useWorkspaces: bindSnapshotSelector(workspaces), + useStore: bindSnapshotSelector(chat), + actions: chat.actions, + renderSlot, + renderSlotChain, + SessionProvider, + views: { list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }, + send, + stop, + open, + updateSessionPrompt, + retrySessionPrompt, } + const view = render() + return { view, chat, send, open, updateSessionPrompt, retrySessionPrompt } +} - it('renders the selected call args and result off the shared store; close fires the injected callback', () => { - const { closeDetails } = benchDetails({ - nodes: [{ - kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', - call: { name: 'bash', argsRaw: '{"cmd":"ls"}' }, - callTime: 500, - content: [{ type: 'text', text: 'file-a\nfile-b' }], - isError: false, callView: null, resultView: null, - }], - }, { turnSeq: 1, callId: 'c1' }) - expect(screen.getByText('bash')).toBeTruthy() - expect(screen.getByText(/"cmd": "ls"/)).toBeTruthy() - expect(screen.getByText(/file-a/)).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '关闭详情' })) - expect(closeDetails).toHaveBeenCalledTimes(1) +describe('ConversationRoot draft ownership', () => { + it('keeps ordinary per-Session composer text in the chat store and selects through runtime actions', () => { + const b = mountConversation() + const box = b.view.getByRole('textbox') + expect((box as HTMLTextAreaElement).value).toBe('ordinary draft') + fireEvent.change(box, { target: { value: 'ordinary revised' } }) + expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') + fireEvent.keyDown(box, { key: 'Enter' }) + expect(b.send).toHaveBeenCalledWith('ordinary revised', 'queue') + fireEvent.click(b.view.getByRole('button', { name: 'Root' })) + expect(b.open).toHaveBeenCalledWith(sid('root')) }) - it('shows the empty hint without a selection and the running state for open calls', () => { - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null) - expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy() - cleanup() - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' }) - expect(screen.getByText('运行中…')).toBeTruthy() - }) - - it('reports an out-of-window call distinctly', () => { - benchDetails({}, { turnSeq: 1, callId: 'ghost' }) - expect(screen.getByText(/不在当前窗口内/)).toBeTruthy() + it('reads a retained prompt from useSession and edits/retries it through the scoped Session', () => { + const b = mountConversation({ + workspaceId: wid('one'), text: 'retry me', phase: 'failed', + retry: 'send', error: 'offline', + }) + const box = b.view.getByRole('textbox') + expect((box as HTMLTextAreaElement).value).toBe('retry me') + expect(b.view.getByRole('alert').textContent).toBe('Message send failed: offline') + fireEvent.change(box, { target: { value: 'revised prompt' } }) + expect(b.updateSessionPrompt).toHaveBeenCalledWith('revised prompt') + expect(b.chat.store.getSnapshot().draft).toBe('ordinary draft') + fireEvent.keyDown(box, { key: 'Enter' }) + expect(b.retrySessionPrompt).toHaveBeenCalledOnce() + expect(b.send).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 9c31e4cc7a..4a9f5e9bd5 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -1,10 +1,10 @@ # @deepseek-ai/dsh-client-ui-layout -Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. The sidebar is fixed-width (it never concedes to viewport pressure — only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5. +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. -Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'. +AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face. -The export surface is the cross-package contract only: the AppFrame trio (+ `AppFrameProps`) consumed by the web shell's assembly, `LayoutService` with its store shapes (`NavState`/`PanelState`/`ViewId`), and the OwnerShare contracts. The concession-chain solver (`computeColumns`) and its geometry constants are package-internal; tests import them from `/src`. +The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`. ## Model Experience diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index dfa8271075..27a4af0386 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -6,10 +6,10 @@ * renders HERE with live parameters from the concession solve, and the * session pair renders under the SessionProvider standard seat (render-prop * form, injected by the renderer because the children declaration contains - * session-scope slots; session slots get sessionId as a framework-standard - * prop, so the owner shares stay empty). Pure component: everything arrives - * through the four prop shares — zero cordis or framework imports, zero - * self-made hooks. + * session-scope slots; session data arrives through framework-standard props + * and each registrant's inject face). Pure component: everything arrives + * through the three framework shares — zero cordis or framework imports, + * zero self-made hooks. */ import { useCallback, useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' @@ -18,7 +18,7 @@ import { computeColumns } from './columns.ts' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' -/** Full composed props: runtime share + child-slot render share + store share (no business face). */ +/** Full composed props: runtime share + child-slot render share + store share. */ export type AppFrameProps = & PropsRuntime<'root'> & PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'conversation.empty'> @@ -82,8 +82,17 @@ function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: } /** The three-column frame (see module doc). SessionProvider arrives as a standard seat (declaring a session-scope child summons it — no framework import). */ -export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: AppFrameProps) { +export function AppFrame({ + useStore, + actions, + renderSlot, + SessionProvider, + useSessions, + useWorkspaces, +}: AppFrameProps) { const panels = useStore((s) => s) + const sessions = useSessions(s => s) + const baselinesReady = useWorkspaces(s => s.baselinesReady) const frameRef = useRef(null) const [viewport, setViewport] = useState(() => window.innerWidth) @@ -143,24 +152,47 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App sidebar keeps the mounted slot at the compact-rail width, and the component sees its rendered state as owner params decided here (collapsed follows the preference, not the resolved width). */} - {renderSlot('sidebar', { collapsed: panels.sidebar === 0, width: cols.sidebar })} + {renderSlot('sidebar', { + collapsed: panels.sidebar === 0, + width: cols.sidebar, + })}
- ( + {!baselinesReady + ? ( <> - {renderSlot('conversation.empty', {})} + +
Loading workspaces and sessions…
+
- )} - > - {() => ( - <> - {/* sessionId is a framework-standard prop on session slots — the owner passes nothing. */} - {renderSlot('conversation', {})} - {renderSlot('details', {})} - - )} -
+ ) + : sessions.intent !== undefined + ? ( + <> + + {renderSlot('conversation.empty', {})} + + + + ) + : ( + ( + <> +
Opening session…
+ + + )} + > + {() => ( + <> + {/* Session data and actions arrive from standard hooks and the registrant's inject face. */} + {renderSlot('conversation', {})} + {renderSlot('details', {})} + + )} +
+ )} {/* The collapsed rail is fixed-width: no resize handle while closed. */} {panels.sidebar > 0 && } {cols.details > 0 && } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 1aeb04593b..6ecb6f6b1d 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -29,8 +29,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { // The 'root' entry itself is the runtime's built-in slot (declared // there); these four are the frame's children, declared by the same - // register() call that contributes AppFrame. Session slots carry no - // owner share: the framework injects sessionId as a standard prop. + // register() call that contributes AppFrame. Session owners never pass + // sessionId: the framework injects it as a standard prop. 'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps } 'conversation': { kind: 'single'; scope: 'session'; owner: ConvOwnerProps } 'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps } @@ -41,12 +41,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { // OwnerShare contracts — the render-side share the slot owner supplies at // renderSlot. Registrants IMPORT these and compose their full component props // through the four-share intersection (PropsRuntime & PropsRenderSlots & -// PropsStore & I). Session owner shares stay literally empty: a phantom -// `sessionId?: never` would intersect with the framework's mandatory -// SessionStandardProps.sessionId and collapse the composed props to never — -// the anti-smuggling guard is mutually exclusive with standard injection, so -// the standard member's own type is the only guard on standard keys. Phantom -// members remain fine on keys the standards never claim (EmptyOwnerProps). +// PropsStore & I). Conversation business state and actions arrive through +// framework-standard hooks and each registrant's inject face, not owner props. /** Sidebar owner share: live column state from the frame's concession solve. */ export interface SidebarOwnerProps { @@ -56,13 +52,13 @@ export interface SidebarOwnerProps { width: number } -/** Conversation owner share: empty — sessionId arrives as a framework-standard prop. */ +/** Conversation owner share: business state and actions belong to the registrant. */ export interface ConvOwnerProps {} /** Details owner share: empty — sessionId arrives as a framework-standard prop. */ export interface DetailsOwnerProps {} -/** Empty-state owner share (ui-conversation registers EmptyState here). */ +/** Empty-state owner share: business state and actions belong to the registrant. */ export interface EmptyOwnerProps { children?: never } /** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ @@ -89,9 +85,8 @@ export function apply(ctx: ClientContext): void { // Exclusive store: the factory itself — the framework instantiates per // entry and delivers useStore/actions to AppFrame as standard props. store: createLayoutStore, - // No business face for the frame (I = {}): the hook's job is the - // assembly side effect wiring the entry's bound actions into the - // cross-plugin service seam. + // The hook's only side effect connects the root store to ctx.layout; + // conversation business actions belong to their registrants. inject: (actions: PanelActions) => { layout.attachPanels(actions) return {} diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index 688a5fa2b7..06bcbe5ae3 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -13,19 +13,19 @@ import { SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, } from './columns.ts' -/** Panel width preferences in px (0 = closed) — the layout store's state. */ -type PanelWidths = { sidebar: number; details: number } +/** Layout store state: panel width preferences in px (0 = closed). */ +type LayoutState = { sidebar: number; details: number } /** * Annotation twin of the actions literal below (the export needs a declared * return type); drift fails assignability at the defineStore call. */ type LayoutActions = { - setSidebar: (draft: PanelWidths, px: number) => void - setDetails: (draft: PanelWidths, px: number) => void - toggleSidebar: (draft: PanelWidths) => void - openDetails: (draft: PanelWidths) => void - closeDetails: (draft: PanelWidths) => void + setSidebar: (draft: LayoutState, px: number) => void + setDetails: (draft: LayoutState, px: number) => void + toggleSidebar: (draft: LayoutState) => void + openDetails: (draft: LayoutState) => void + closeDetails: (draft: LayoutState) => void } /** @@ -36,9 +36,9 @@ type LayoutActions = { * open/close transitions write 0 / the default explicitly. * @returns the store handle (spec + type + identity + factory in one). */ -export function createLayoutStore(): EngineStoreHandle { - return defineStore({ - init: () => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }), +export function createLayoutStore(): EngineStoreHandle { + const handle = defineStore({ + init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }), persist: 'dsh.layout.panels', actions: { setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) }, @@ -48,4 +48,5 @@ export function createLayoutStore(): EngineStoreHandle { d.details = 0 }, }, }) + return handle } diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 9f8653739d..beebe0aeea 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -17,9 +17,13 @@ import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame. import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts' +import type { + SessionId, SessionListState, WorkspaceId, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' // Session-mode switch for the SessionProvider stub prop. const sessionMode = { current: true } +const baselinesReady = { current: true } // Render-prop contract stub fed through the standard seat prop (the renderer // injects the real one in production): session mode runs children(id), empty @@ -59,13 +63,31 @@ function mountFrame() { if (key === 'details') return
return
}) as AppFrameProps['renderSlot'] - const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never + const sessionId = 's-test' as SessionId + const workspaceId = 'w-test' as WorkspaceId + const sessionState = { + ids: sessionMode.current ? [sessionId] : [], + byId: sessionMode.current + ? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, updatedAt: 1 } } + : {}, + current: sessionMode.current ? sessionId : undefined, + phase: 'ready', + intent: sessionMode.current + ? undefined + : { sessionId: 'intent' as SessionId, target: { kind: 'workspace', workspaceId }, prompt: '', phase: 'connecting' }, + } as SessionListState + const useSessions = ((sel: (s: SessionListState) => unknown) => sel(sessionState)) as never + const workspaceState: WorkspaceListState = { + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: baselinesReady.current, recentWorkspaceId: undefined, + } const utils = render( unknown) => sel(workspaceState)) as never} SessionProvider={SessionProviderStub} />, ) @@ -91,6 +113,7 @@ function drag(handle: Element, fromX: number, toX: number): void { beforeEach(() => { frameWidth = 1920 sessionMode.current = true + baselinesReady.current = true localStorage.clear() // the layout store persists; instances must not bleed across tests vi.useFakeTimers() vi.stubGlobal('ResizeObserver', ResizeObserverStub) @@ -131,13 +154,22 @@ describe('AppFrame', () => { expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({}) }) - it('renders the empty branch through conversation.empty when no session is current', () => { + it('keeps a connecting page-local Session intent in conversation.empty', () => { sessionMode.current = false const { slotCalls, getByTestId, queryByTestId } = mountFrame() expect(getByTestId('empty-content')).toBeTruthy() expect(queryByTestId('center-content')).toBeNull() expect(slotCalls.map((c) => c.key)).toContain('conversation.empty') expect(slotCalls.map((c) => c.key)).not.toContain('conversation') + expect(slotCalls.find((c) => c.key === 'conversation.empty')!.props).toEqual({}) + }) + + it('keeps the loading branch until both object-layer baselines are ready', () => { + baselinesReady.current = false + const { slotCalls, getByRole } = mountFrame() + expect(getByRole('status').textContent).toContain('Loading workspaces and sessions') + expect(slotCalls.map((c) => c.key)).not.toContain('conversation') + expect(slotCalls.map((c) => c.key)).not.toContain('conversation.empty') }) it('sidebar slot receives live concession output as owner props', () => { diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index b9682193fb..bed0987f62 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -22,12 +22,12 @@ async function bench() { describe('ui-layout client apply', () => { it('declares its service dependencies', () => { - expect(inject).toContain('slots') + expect(inject).toEqual(['slots']) }) it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => { const { ctx, slots } = await bench() - const fiber = ctx.plugin({ inject: ['slots'], apply }) + const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() expect(ctx.get('layout')).toBeInstanceOf(LayoutService) // The one register() call occupied 'root'… @@ -39,9 +39,23 @@ describe('ui-layout client apply', () => { expect(slots.spec('conversation.empty')).toEqual({ kind: 'single', scope: 'root' }) }) + it('injects no business face and attaches the layout actions', async () => { + const { ctx, slots } = await bench() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const actions = { + setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(), + } + const injected = (slots.entries('root')[0]!.inject as (actions: never) => object)(actions as never) + expect(injected).toEqual({}) + const layout = ctx.get('layout') as LayoutService + layout.toggleSidebar() + expect(actions.toggleSidebar).toHaveBeenCalledOnce() + }) + it('teardown unwinds the service, the root registration, and the child declarations', async () => { const { ctx, slots } = await bench() - const fiber = ctx.plugin({ inject: ['slots'], apply }) + const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() await fiber.dispose() expect(ctx.get('layout')).toBeUndefined() diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 44c35eb517..7f5c3555bd 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -22,12 +22,14 @@ "dependencies": { "clsx": "^2.0.0", "react": "^18.2.0", + "react-dom": "^18.2.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "cordis": "^4.0.0-rc.7" }, "files": [ diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 3cbcb62d29..b55abe094d 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -7,6 +7,8 @@ * r12, inverted hairline border, shadow-lv3, 4px inset padding. */ .list, .submenu { + /* min-widths below are the design's outer card widths — include the pad. */ + box-sizing: border-box; padding: 4px; display: flex; flex-direction: column; @@ -17,12 +19,22 @@ box-shadow: var(--dsw-shadow-lv3); } +/* Primary card is 218 wide in the design across both hosts. */ .list { position: absolute; top: calc(100% + 4px); left: 0; z-index: 100; - min-width: 130px; + min-width: 218px; +} + +/* Portal mode: fixed in the viewport, coordinates supplied inline from the + * anchor rect (side/align resolved in JS, the in-place offset rules above + * don't apply). */ +.portal { + position: fixed; + top: auto; + left: auto; } /* Open above the anchor (empty-state workspace chip: figma 122:9481). */ @@ -116,7 +128,7 @@ bottom: -4px; left: calc(100% + 10px); z-index: 101; - min-width: 160px; + min-width: 163px; } .submenu::before { diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 0b07c26357..de015ee534 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -1,10 +1,13 @@ // Menu: minimal controlled dropdown (group-by pickers, project selectors). -// Pure CSS positioning relative to the anchor wrapper — no portal, no popper. +// Default: pure CSS positioning relative to the anchor wrapper — no popper. +// Opt-in `portal` renders the list into document.body, fixed-positioned from +// the anchor rect, for anchors inside overflow-clipping containers (sidebar). // The owner controls `open`; outside-click closing uses one document listener // active only while open. Submenus open on hover/focus inside the same root. -import { useEffect, useRef, useState } from 'react' -import type { ReactNode } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCheckOutline16 } from './icons/index.tsx' import css from './Menu.module.css' @@ -43,9 +46,19 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator { * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). * @param props.side - open below (`bottom`, default) or above (`top`) the anchor. + * @param props.portal - render the list into document.body, fixed-positioned + * from the anchor rect (repositions on scroll/resize while open). Use when an + * ancestor's overflow clipping would crop the in-place list; default false + * keeps the pure-CSS in-place behavior. + * @param props.getAnchorRect - portal mode only: supply the anchor rect + * directly (e.g. from a host-owned trigger button) instead of measuring the + * Menu's own wrapper span. Required when the wrapper isn't itself laid out at + * the trigger (render-prop anchors, effect-positioned proxies — measuring the + * wrapper there races the host's layout effects). Called on open and on every + * scroll/resize; return null to skip placement for that frame. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] @@ -54,10 +67,44 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align onClose: () => void align?: 'start' | 'end' side?: 'bottom' | 'top' + portal?: boolean + getAnchorRect?: () => DOMRect | null className?: string }) { const rootRef = useRef(null) + const listRef = useRef(null) const [openSubmenuId, setOpenSubmenuId] = useState(null) + const [fixedPos, setFixedPos] = useState(null) + + // Portal mode: fixed-position the list from the anchor rect before paint; + // track the anchor while open (capture-phase scroll catches nested panes). + // getAnchorRect trumps measuring the wrapper span: a child layout effect + // runs before the parent's, so a wrapper the host positions in its own + // effect measures stale here — the host callback owns the truth instead. + useLayoutEffect(() => { + if (!open || !portal) { setFixedPos(null); return } + const place = () => { + let r: DOMRect | null + if (getAnchorRect !== undefined) { + r = getAnchorRect() + } else { + /* v8 ignore next 2 -- the ref is attached before the layout effect runs and the listeners die with it. */ + r = rootRef.current?.getBoundingClientRect() ?? null + } + if (r === null) return + setFixedPos({ + ...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }), + ...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }), + }) + } + place() + window.addEventListener('scroll', place, true) + window.addEventListener('resize', place) + return () => { + window.removeEventListener('scroll', place, true) + window.removeEventListener('resize', place) + } + }, [open, portal, align, side, getAnchorRect]) useEffect(() => { if (!open) { @@ -65,7 +112,11 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align return } const onPointerDown = (e: PointerEvent) => { - if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose() + if (!(e.target instanceof Node)) return + // The portaled list is outside the anchor subtree; check both. + if (rootRef.current?.contains(e.target) === true) return + if (listRef.current?.contains(e.target) === true) return + onClose() } const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() @@ -78,11 +129,13 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } }, [open, onClose]) - return ( - - {anchor} - {open && ( -
+ const list = open && (!portal || fixedPos !== null) && ( +
{items.map(entry => { if (isSeparator(entry)) { return
@@ -137,8 +190,13 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
) })} -
- )} +
+ ) + + return ( + + {anchor} + {portal ? (list !== false && createPortal(list, document.body)) : list} ) } diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 49026f7a5f..02c075803e 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -40,10 +40,12 @@ width: 100%; } -/* Header pad (figma Title row): pt 22 / pl 24 / pr 14 / pb 12. */ +/* Header row (figma Title row): pad l24/t22/r14/b12, SPACE_BETWEEN — + * title left, close button right. */ .header { display: flex; - flex-direction: column; + align-items: center; + justify-content: space-between; gap: 8px; padding: 22px 14px 12px 24px; } @@ -52,21 +54,43 @@ margin: 0; font-size: 16px; line-height: 24px; - font-weight: 500; + font-weight: 510; color: var(--dsw-alias-label-primary); } +.close { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +.close:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Description and body share the 332px content column (24px side pads). */ .description { margin: 0; + padding: 0 24px; font-size: 14px; line-height: 22px; - color: var(--dsw-alias-label-secondary); + font-weight: 400; + color: var(--dsw-alias-label-primary); } .body { display: flex; flex-direction: column; min-width: 0; + margin-top: 20px; padding: 0 24px; } diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index cdbe1060bf..820ff3d7a3 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -5,6 +5,7 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' +import { IconCloseOutline16 } from './icons/index.tsx' import css from './Modal.module.css' /** @@ -49,10 +50,13 @@ export function Modal({ open, onClose, title, description, children, footer, cla

{title}

- {description !== undefined && description !== '' && ( -

{description}

- )} +
+ {description !== undefined && description !== '' && ( +

{description}

+ )} {children !== undefined &&
{children}
}
{footer !== undefined &&
{footer}
} diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index f62a397535..e2a49f6579 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -7,8 +7,8 @@ // it escapes ancestor overflow clipping (the sidebar rail clips its column) // without a portal. -import { cloneElement, useEffect, useRef, useState } from 'react' -import type { FocusEventHandler, MouseEventHandler, ReactElement, Ref } from 'react' +import { cloneElement, useCallback, useEffect, useRef, useState } from 'react' +import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' import css from './Tooltip.module.css' /** Bubble placement relative to the anchor. */ @@ -28,11 +28,19 @@ interface AnchorProps { * @param props.label - bubble text. * @param props.side - placement relative to the anchor (default 'right'). * @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions). - * @param props.children - a single anchor element. Tooltip owns its ref (no current consumer passes one). + * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. */ export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement }) { const anchor = useRef(null) + // React 18 keeps the element's ref outside props; forward it so wrapping an + // anchor in Tooltip never silently severs the owner's ref. + const childRef = (children as ReactElement & { ref?: Ref }).ref + const mergedRef = useCallback((el: HTMLElement | null) => { + anchor.current = el + if (typeof childRef === 'function') childRef(el) + else if (childRef != null) (childRef as MutableRefObject).current = el + }, [childRef]) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). @@ -61,7 +69,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { return ( <> {cloneElement(children, { - ref: anchor, + ref: mergedRef, onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() }, onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index a4b286ced7..440f4ba6ef 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -170,6 +170,71 @@ describe('Menu', () => { fireEvent.mouseLeave(wrap) expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull() }) + + it('portal mode prefers getAnchorRect over measuring its own wrapper', () => { + const rect = { left: 40, right: 72, top: 100, bottom: 128, width: 32, height: 28, x: 40, y: 100, toJSON: () => ({}) } as DOMRect + render( + rect} + anchor={null} + items={items} + onSelect={() => {}} + onClose={() => {}} + />) + const menu = screen.getByRole('menu') + // side=bottom, align=start: below the host-supplied rect, left-aligned. + expect(menu.style.left).toBe('40px') + expect(menu.style.top).toBe('132px') + }) + + it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => { + render( + null} + anchor={null} + items={items} + onSelect={() => {}} + onClose={() => {}} + />) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('portal mode renders the list under body, positions it fixed, and still closes on outside pointerdown', () => { + const onSelect = vi.fn() + const onClose = vi.fn() + const { container } = render( + trigger} items={items} onSelect={onSelect} onClose={onClose} />) + const menu = screen.getByRole('menu') + // Outside the anchor wrapper subtree — overflow-clipping ancestors can't crop it. + expect(container.contains(menu)).toBe(false) + expect(menu.parentElement).toBe(document.body) + expect(menu.style.top).not.toBe('') + fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) + expect(onSelect).toHaveBeenCalledWith('a') + fireEvent.pointerDown(menu) + expect(onClose).not.toHaveBeenCalled() + // Non-Node targets (e.g. window itself) are ignored, not treated as outside. + const nonNodeTarget = new Event('pointerdown', { bubbles: true }) + Object.defineProperty(nonNodeTarget, 'target', { value: window }) + document.dispatchEvent(nonNodeTarget) + expect(onClose).not.toHaveBeenCalled() + fireEvent.pointerDown(document.body) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('portal mode positions from the opposite edges for align=end / side=top', () => { + render( + trigger} items={items} onSelect={() => {}} onClose={() => {}} />) + const menu = screen.getByRole('menu') + expect(menu.style.right).not.toBe('') + expect(menu.style.bottom).not.toBe('') + expect(menu.style.left).toBe('') + expect(menu.style.top).toBe('') + }) }) describe('Modal', () => { diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 3b3af8373c..6741921c66 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -104,6 +104,26 @@ describe('Tooltip', () => { expect(screen.queryByRole('tooltip')).toBeNull() }) + it('forwards the anchor element to the child ref (object and callback)', () => { + const objectRef = { current: null as HTMLButtonElement | null } + const callbackRef = vi.fn() + const { rerender } = render( + + + , + ) + expect(objectRef.current).toBe(screen.getByText('anchor')) + // Tooltip's own positioning still works through the merged ref. + fireEvent.mouseEnter(screen.getByText('anchor')) + expect(screen.getByRole('tooltip')).toBeTruthy() + rerender( + + + , + ) + expect(callbackRef).toHaveBeenCalledWith(screen.getByText('anchor')) + }) + it('drops an already-visible bubble when disabled flips mid-hover', () => { const { rerender } = render( diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 9a8ac9bb57..2e08aa61b3 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -1,7 +1,9 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -22,6 +24,7 @@ const kit = { sessionId: SID, useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, + useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, } const QUESTIONS = [ diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index c165455b1a..d8c2f61ee0 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,10 +1,10 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Top-level New Session / New Workspace clear the selection onto `conversation.empty`; per-project "+" still create-then-opens. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. +New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar. -There is no plugin store: rows derive in the component (`useMemo` over the `useSessions` snapshot + local expansion/search state) through the pure `deriveRows` in `tree.ts`. +`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` child slot, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly). diff --git a/packages/client/ui-sidebar/src/client/Rows.module.css b/packages/client/ui-sidebar/src/client/Rows.module.css index 18539a5f61..f996f03348 100644 --- a/packages/client/ui-sidebar/src/client/Rows.module.css +++ b/packages/client/ui-sidebar/src/client/Rows.module.css @@ -104,6 +104,18 @@ line-height: 20px; } +.renameInput { + min-width: 0; + font-size: 14px; + line-height: 20px; + padding: 0 2px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 4px; + background: var(--dsw-alias-button-elevated-fill); + color: inherit; + outline: none; +} + .sessionRow .title { flex: 1; } diff --git a/packages/client/ui-sidebar/src/client/Rows.tsx b/packages/client/ui-sidebar/src/client/Rows.tsx index 53f8bbe14f..6535a9a08a 100644 --- a/packages/client/ui-sidebar/src/client/Rows.tsx +++ b/packages/client/ui-sidebar/src/client/Rows.tsx @@ -5,10 +5,10 @@ */ import clsx from 'clsx' import { - IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTriangleRightFill14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ProjectRow, SessionRow } from './tree.ts' +import type { GroupNode, SessionNode } from './tree.ts' import { formatRelativeTime } from './tree.ts' import css from './Rows.module.css' @@ -16,20 +16,21 @@ import css from './Rows.module.css' const INDENT_STEP = 16 /** - * Project (workspace) row: 54px, folder + title + session count; hover - * reveals the chevron and the more/create buttons. - * @param props.row - derived project row. - * @param props.active - group contains the selected session (blue open folder). + * Project (workspace) header row: 54px, folder + title + session count; + * hover reveals the chevron and create button. `containsCurrent` arrives on + * the node (derivation fact, no renderer scan). + * @param props.group - derived group node. * @param props.onToggle - expand/collapse the group. - * @param props.onCreate - create a session inside this group. + * @param props.onCreate - start a frontend Session inside this Workspace. * @returns the row element. */ -export function ProjectRowItem({ row, active, onToggle, onCreate }: { - row: ProjectRow - active: boolean +export function ProjectRowItem({ group, onToggle, onCreate }: { + group: GroupNode onToggle: () => void onCreate: () => void }) { + const row = group + const active = group.expanded && group.containsCurrent const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}` return (
@@ -44,14 +45,10 @@ export function ProjectRowItem({ row, active, onToggle, onCreate }: { {count} - {/* Row menu contents are not designed yet (figma draft notes); the button is the reserved anchor. */} - @@ -105,11 +122,22 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: { {row.running && } {row.title} {formatRelativeTime(row.updatedAt, now)} - - -
) + return ( + <> + {ownRow} + {node.children.map(child => ( + + ))} + + ) } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 621b33fc66..591aef2330 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -339,24 +339,33 @@ pointer-events: none; } -/* Batch separator (figma 133:7661): 20px spacer after an expanded project's - session run, before the next project row. */ -.batchGap { - flex: none; - height: 20px; -} - -/* Tree list: the only scrolling region. */ +/* Tree list: the only scrolling region. Block, not a flex column: as flex + items the 54/34 rows would shrink under content overflow (scrollHeight + collapses onto clientHeight and wheel scrolling dies); block children keep + their design heights and the 4px rhythm rides margins instead of gap. */ .list { flex: 1; min-height: 0; overflow-y: auto; - display: flex; - flex-direction: column; - gap: 4px; padding-bottom: 12px; } +/* One workspace section: header row + expanded session run. Rows inside + keep the former flat-list 4px gap as sibling margins; the inter-group + breathing room (figma 133:7661 batch separator, 20px after an expanded + run) rides the NEXT section's top margin so the last group adds none. */ +.groupSection > * + * { + margin-top: 4px; +} + +.groupSection + .groupSection { + margin-top: 4px; +} + +.groupSection:has([aria-expanded='true']) + .groupSection { + margin-top: 20px; +} + .empty { padding: 16px 12px; color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index ac1e33866b..931eb1ba17 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -7,7 +7,7 @@ * (one icon each, same top-down order) fading in as the slide ends. Rail * search expands and focuses the search box. */ -import { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { BrandWordmark, FishLogo, @@ -15,9 +15,10 @@ import { IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootComponentProps } from './contract/slots.ts' -import { deriveRows } from './tree.ts' -import { ProjectRowItem, SessionRowItem } from './Rows.tsx' +import { deriveGroups, UNGROUPED_KEY } from './tree.ts' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx' import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ @@ -27,7 +28,7 @@ const COLLAPSE_SETTLE_MS = 150 const EXPAND_SLIDE_MS = 300 const GROUP_BY_ITEMS = [ - { id: 'workspace', label: 'WorkSpace' }, + { id: 'workspace', label: 'Workspace' }, // Only workspace grouping is implemented. { id: 'update', label: 'Update', disabled: true }, { id: 'status', label: 'Status', disabled: true }, @@ -63,62 +64,74 @@ function GroupByMenu() { ) } -type SessionTreeProps = Pick & { +type SessionTreeProps = Pick< + SidebarRootComponentProps, + 'useSessions' | 'startSession' | 'open' +> & { + workspaces: readonly WorkspaceView[] /** Live search filter owned by the root (the query outlives the tree). */ query: string } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) { +function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) { const list = useSessions((s) => s) - // Selection belongs to the sessions snapshot, not layout state. - const current = useSessions((s) => s.current) + const current = list.current const [expandedProjects, setExpandedProjects] = useState([]) const [expandedSessions, setExpandedSessions] = useState([]) - const rows = useMemo( - () => deriveRows(list, { expandedProjects, expandedSessions, query }), - [list, expandedProjects, expandedSessions, query], + // Re-expand when publication moves the selected intent into a real Workspace. + const intent = list.intent + const intentWorkspaceId = intent?.target.kind === 'workspace' + ? intent.target.workspaceId + : undefined + const currentGroup = current === undefined + ? undefined + : intent?.sessionId === current + ? intentWorkspaceId + : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) + ?? UNGROUPED_KEY + useEffect(() => { + if (current === undefined || currentGroup === undefined) return + setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) + }, [current, currentGroup]) + const groups = useMemo( + () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), + [list, workspaces, expandedProjects, expandedSessions, query], ) const now = Date.now() - // Presentational lookup (not tree derivation): the group holding the - // selected session gets the active folder; only expanded groups can show it. - let activeGroup: string | undefined - if (current !== undefined) { - for (const row of rows) { - if (row.type === 'session' && row.id === current) { activeGroup = row.groupKey; break } - } - } - return (
- {rows.length === 0 && ( + {groups.length === 0 && (
{query === '' ? 'No sessions yet' : 'No matches'}
)} - {rows.map((row, i) => row.type === 'project' - ? ( - - {/* Batch separator: a project row closing an expanded session run (figma 133:7661). */} - {i > 0 && rows[i - 1]!.type === 'session' && } - { setExpandedProjects((l) => toggled(l, row.key)) }} - onCreate={() => { onCreate(row.cwd) }} - /> - - ) - : ( - ( + // Group section: header row + expanded session subtree. The + // inter-group breathing room (former flat-list batch separator) + // is the section's own margin (SidebarRoot.module.css). +
+ { setExpandedProjects((l) => toggled(l, group.key)) }} + onCreate={() => { + if (group.workspaceId !== undefined) startSession(group.workspaceId) + }} + /> + {group.intentHere && } + {group.sessions.map(node => ( + { onOpen(row.id) }} - onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }} + onOpen={open} + onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }} /> ))} +
+ ))}
@@ -130,11 +143,27 @@ function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). * @returns the sidebar element tree. */ -export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { +export function SidebarRoot({ + collapsed, + width, + useSessions, + useWorkspaces, + startSession, + open, + toggleSidebar, + renderSlot, +}: SidebarRootComponentProps) { + const workspaces = useWorkspaces(state => state.items) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') const searchInput = useRef(null) + // Section-header + opens the workspace picker (same popover in wide and + // rail states; the hole sits beside the button and opens rightward). + const [wsPickerOpen, setWsPickerOpen] = useState(false) + // Placement anchor for the picker popover: the slot span renders elsewhere + // in the DOM, so the picker positions off this button's rect. + const wsPlusRef = useRef(null) // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. @@ -188,7 +217,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o type="button" className={clsx(css.iconButton, css.toggle)} aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'} - onClick={() => { onToggleSidebar() }} + onClick={() => { toggleSidebar() }} > {!wide && } {/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */} @@ -202,7 +231,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o type="button" className={css.newSession} aria-label="New session" - onClick={() => { onCreate() }} + onClick={() => { startSession() }} > {wide && New Session} @@ -210,18 +239,29 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
- {wide && WorkSpace} + {wide && Workspaces} {wide && } + {/* Picker hole beside the + (same site in wide and rail states). */} + {renderSlot('sidebar.workspace', { + open: wsPickerOpen, + anchorRef: wsPlusRef, + onPick: (workspaceId) => { + setWsPickerOpen(false) + startSession(workspaceId) + }, + onClose: () => { setWsPickerOpen(false) }, + })}
{/* Expanded: the row is a click-to-focus field (the leading icon is @@ -233,7 +273,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o className={css.searchButton} aria-label="Search sessions" tabIndex={collapsed ? 0 : -1} - onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }} + onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }} > @@ -263,7 +303,15 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o {/* Always-mounted seat: its flex slot pins the foot to the bottom in both states while the tree itself is wide-only. */}
- {wide && } + {wide && ( + + )}
diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index a5ce65ef59..0334ca88c9 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -1,42 +1,69 @@ /** * Sidebar slot contract: the registrant-side props composition for the - * layout-owned `sidebar` slot. The own injected share is declared here (a - * share's type lives with whoever wires it); the runtime share — owner - * props {collapsed,width} plus the standard useSessions hook — is - * PropsRuntime<'sidebar'>, resolved off ui-layout's SlotMap declaration and - * never re-stated. Single domain — this is the package's whole contract - * surface. + * layout-owned `sidebar` slot and the Workspace picker hole declared here. + * The runtime share combines layout-owned page state and actions with the + * global useSessions and useWorkspaces hooks; the injected share adds the + * runtime navigation actions and sidebar toggle. */ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { RefObject } from 'react' +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' -/** - * Registrant-private injected share (arrives via the register inject - * factory): plain cross-service callbacks only — tree data rides the - * standard useSessions hook and viewing state is component-local. A type - * alias, not an interface: the alias carries an implicit index signature, - * so the factory's return crosses the registry's `Record` - * boundary uncast. - */ -export type SidebarRootInjected = { - /** Open (switch to) a session. */ - onOpen: (id: SessionId) => void - /** - * New-session affordance: no cwd clears selection onto the empty-state - * launch; a cwd create-then-opens a session in that project group. - */ - onCreate: (cwd?: string) => void - /** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */ - onToggleSidebar: () => void +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** + * The workspace picker hole in the sidebar section header (anchored at + * the + button). Declared by this package's 'sidebar' entry (declaring + * is claiming); ui-workspace registers the picker. + */ + 'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps } + } } /** - * Full component props: the framework runtime share (owner {collapsed,width} - * + standard useSessions) plus the own injected share. No children are - * declared and no store is registered, so no PropsRenderSlots/PropsStore - * term appears. + * Owner share of the sidebar workspace hole: popover geometry plus the + * sidebar's pick semantics. The picked Host Workspace is already real; the + * callback starts a frontend Session Intent targeted to it. */ -export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected +export interface SidebarWorkspaceOwnerProps { + /** Popover visibility (+ button toggle state, host-local). */ + open: boolean + /** + * The + button element — the popover's placement anchor. The picker's + * slot span renders elsewhere in the DOM, so without this the menu + * positions off the zero-size placement span (order-dependent). Optional + * only until the host passes it; absent falls back to in-place placement. + */ + anchorRef?: RefObject + /** Start a frontend Session in a selected or newly created real Workspace. */ + onPick: (workspaceId: WorkspaceId) => void + /** Close the popover (outside click / Escape / post-pick). */ + onClose: () => void +} + +/** + * Registrant-private injected share (arrives via the register inject + * factory). Host Workspace and Session data use the global framework hooks; + * navigation and panel actions are plain callbacks, and viewing state remains + * component-local. A type alias supplies the implicit index signature required + * by the registry. + */ +export type SidebarRootInjected = { + /** Start or replace the current frontend Session Intent. */ + startSession: (workspaceId?: WorkspaceId, prompt?: string) => void + /** Open a real Session. */ + open: (sessionId: SessionId) => void + /** Toggle the sidebar column through the layout service. */ + toggleSidebar: () => void +} + +/** + * Full component props: layout owner state/actions plus global useSessions + * and useWorkspaces, the declared Workspace picker render share, and this + * package's injected callback. No store is registered. + */ +export type SidebarRootComponentProps = + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index e0c3a4b37d..0a1c8ebb12 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,36 +1,30 @@ /** Registers the sidebar UI into the layout-owned slot. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' -export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts' +export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts' /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'sessions'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces'] /** Registers the sidebar component and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ - // Selection belongs to the sessions service; layout owns only panel geometry. - onOpen: (id) => { ctx.sessions.open(id) }, - onCreate: (cwd) => { - // Top-level New Session / New Workspace: clear selection so AppFrame - // shows conversation.empty (EmptyState + shared InputBar). Per-project - // "+" still create-then-opens into that cwd until workspace seeding - // reaches the empty-state picker. - if (cwd === undefined) { - ctx.sessions.clear() - return - } - void ctx.sessions.create({ cwd }) - .then((id: SessionId) => { ctx.sessions.open(id) }) - }, - onToggleSidebar: () => { ctx.layout.toggleSidebar() }, + startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) }, + open: (sessionId) => { ctx.sessions.open(sessionId) }, + toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) ctx.effect( - () => ctx.slots.register({ name: 'sidebar', inject: injectProps }, SidebarRoot), + () => ctx.slots.register({ + name: 'sidebar', + // SidebarRoot owns this picker site; ui-workspace registers the shared + // picker that selects a Host Workspace for a frontend Session Intent. + children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } }, + inject: injectProps, + }, SidebarRoot), 'ui-sidebar: slot registration', ) } diff --git a/packages/client/ui-sidebar/src/client/tree.ts b/packages/client/ui-sidebar/src/client/tree.ts index 1d5782cb3c..64fda519e6 100644 --- a/packages/client/ui-sidebar/src/client/tree.ts +++ b/packages/client/ui-sidebar/src/client/tree.ts @@ -1,41 +1,46 @@ -/** Pure derivation of flat sidebar rows from sessions and local view state. */ -import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +/** + * Derives the sidebar tree from Host Workspace order and membership. + * Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render. + */ +import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' -/** Group key for sessions without a project directory. */ +/** Group key for Sessions outside every Workspace. */ export const UNGROUPED_KEY = '' -/** Display label for the ungrouped project row. */ +/** Display label for the ungrouped bucket row. */ export const UNGROUPED_LABEL = 'Ungrouped' -/** Project (workspace) row: 54px, two lines (label + session count). */ -export interface ProjectRow { - type: 'project' - /** Group key: the cwd, or {@link UNGROUPED_KEY}. */ - key: string - cwd: string | undefined - label: string - /** Total sessions in the group, including hidden ones. */ - sessionCount: number - expanded: boolean -} - -/** Session row: 34px single line; depth drives the 22px indent steps. */ -export interface SessionRow { - type: 'session' +/** One session node of a group's visible tree (34px row; children render indented one step). */ +export interface SessionNode { id: SessionId - /** Owning project group key (selection -> active-folder lookup). */ - groupKey: string title: string - /** 0 = directly under the project row. */ - depth: number + /** Visible children, already expansion/search-filtered (empty when folded). */ + children: readonly SessionNode[] + /** The session HAS children in the data (the twist renders even while folded). */ hasChildren: boolean expanded: boolean running: boolean updatedAt: number } -/** One flat sidebar list row. */ -export type SidebarRow = ProjectRow | SessionRow +/** One workspace group section: header row facts + the visible session tree. */ +export interface GroupNode { + /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ + key: string + /** Backing Workspace id; absent only for the ungrouped bucket. */ + workspaceId: WorkspaceId | undefined + cwd: string | undefined + label: string + /** Total sessions in the group, including hidden ones. */ + sessionCount: number + expanded: boolean + /** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */ + containsCurrent: boolean + /** The frontend Session Intent points here: render one "New session" row. */ + intentHere: boolean + /** Visible roots (empty while the group is folded). */ + sessions: readonly SessionNode[] +} /** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */ export interface TreeView { @@ -46,17 +51,18 @@ export interface TreeView { interface Group { key: string + workspaceId: WorkspaceId | undefined cwd: string | undefined label: string summaries: Map roots: SessionId[] children: Map - latest: number } /** - * Project display label: basename of the group directory. - * @param cwd - project directory, or undefined for the ungrouped bucket. + * Directory display label: basename of the path (both separators accepted). + * Ungrouped-bucket fallback for surfaces without a workspace title. + * @param cwd - directory path, or undefined for the ungrouped bucket. * @returns basename, the raw cwd when it has no basename, or the ungrouped label. */ export function projectLabel(cwd: string | undefined): string { @@ -71,32 +77,32 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { return a.id < b.id ? -1 : 1 } -function groupByCwd(list: SessionListState): Group[] { - const byKey = new Map() - for (const id of list.ids) { - const s = list.byId[id] - if (s === undefined) continue - const key = s.cwd ?? UNGROUPED_KEY - const members = byKey.get(key) - if (members === undefined) byKey.set(key, [s]) - else members.push(s) - } - const groups: Group[] = [] - for (const [key, members] of byKey) { - const summaries = new Map(members.map(m => [m.id, m])) - const children = new Map() - const roots: SessionSummary[] = [] - for (const m of members) { - // A session is a tree child only when its parent lives in the same - // group; cross-group or unknown parents degrade to group roots. - if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) { - const kids = children.get(m.parentId) - if (kids === undefined) children.set(m.parentId, [m.id]) - else kids.push(m.id) - } else { - roots.push(m) - } +/** Build one group's parent/child tree from an ordered member list. */ +function buildGroup( + key: string, + workspaceId: WorkspaceId | undefined, + cwd: string | undefined, + label: string, + members: readonly SessionSummary[], + order: 'account' | 'recency', +): Group { + const summaries = new Map(members.map(m => [m.id, m])) + const children = new Map() + const roots: SessionSummary[] = [] + for (const m of members) { + // A session is a tree child only when its parent lives in the same + // group; cross-group or unknown parents degrade to group roots. + if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) { + const kids = children.get(m.parentId) + if (kids === undefined) children.set(m.parentId, [m.id]) + else kids.push(m.id) + } else { + roots.push(m) } + } + // Workspace order is the member iteration order (workspace.sessionIds), so + // attached groups keep insertion order; Ungrouped sorts by recency. + if (order === 'recency') { roots.sort(byRecency) for (const kids of children.values()) { kids.sort((a, b) => { @@ -107,48 +113,63 @@ function groupByCwd(list: SessionListState): Group[] { return byRecency(sa, sb) }) } - const rootIds = roots.map(r => r.id) - // parentId cycles (host bug) leave members unreachable from any root; - // surface them as extra roots — the flatten walk's visited set stops - // loops. Each node sits in at most one kids list and roots have no - // in-group parent, so the scan pushes every reachable node exactly once. - const reachable = new Set(rootIds) - const stack = [...rootIds] - while (stack.length > 0) { - const top = stack.pop() - /* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */ - if (top === undefined) break - for (const kid of children.get(top) ?? []) { - reachable.add(kid) - stack.push(kid) - } - } - for (const m of [...members].sort(byRecency)) { - if (!reachable.has(m.id)) rootIds.push(m.id) - } - let latest = 0 - for (const m of members) latest = Math.max(latest, m.updatedAt) - groups.push({ - key, - cwd: key === UNGROUPED_KEY ? undefined : key, - label: projectLabel(key === UNGROUPED_KEY ? undefined : key), - summaries, - roots: rootIds, - children, - latest, - }) } - groups.sort((a, b) => b.latest - a.latest || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0)) + const rootIds = roots.map(r => r.id) + // parentId cycles (host bug) leave members unreachable from any root; + // surface them as extra roots — the flatten walk's visited set stops + // loops. Each node sits in at most one kids list and roots have no + // in-group parent, so the scan pushes every reachable node exactly once. + const reachable = new Set(rootIds) + const stack = [...rootIds] + while (stack.length > 0) { + const top = stack.pop() + /* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */ + if (top === undefined) break + for (const kid of children.get(top) ?? []) { + reachable.add(kid) + stack.push(kid) + } + } + for (const m of members) { + if (!reachable.has(m.id)) rootIds.push(m.id) + } + return { key, workspaceId, cwd, label, summaries, roots: rootIds, children } +} + +/** + * Group Sessions by Host Workspace: one group per entity in stable Host + * order, with members resolved from sessionIds in their stored order. Sessions + * outside every Workspace trail in the recency-ordered Ungrouped bucket. + */ +function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] { + const groups: Group[] = [] + const accounted = new Set() + for (const workspace of workspaces) { + const members: SessionSummary[] = [] + for (const id of workspace.sessionIds) { + const summary = list.byId[id] + if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands + members.push(summary) + accounted.add(id) + } + groups.push(buildGroup( + workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account', + )) + } + const stray = list.ids + .map(id => list.byId[id]) + .filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id)) + if (stray.length > 0) { + groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) + } return groups } -function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boolean, expanded: boolean): SessionRow { +function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode { return { - type: 'session', id: s.id, - groupKey: g.key, title: s.displayTitle, - depth, + children, hasChildren, expanded, running: s.running, @@ -156,20 +177,20 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo } } -function flattenVisible(g: Group, expandedSessions: ReadonlySet, rows: SidebarRow[]): void { +function buildVisible(g: Group, expandedSessions: ReadonlySet): SessionNode[] { const visited = new Set() - const walk = (id: SessionId, depth: number): void => { - if (visited.has(id)) return + const walk = (id: SessionId): SessionNode | null => { + if (visited.has(id)) return null visited.add(id) const s = g.summaries.get(id) /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return + if (s === undefined) return null const kids = g.children.get(id) ?? [] const expanded = expandedSessions.has(id) - rows.push(sessionRow(g, s, depth, kids.length > 0, expanded)) - if (expanded) for (const kid of kids) walk(kid, depth + 1) + const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : [] + return sessionNode(s, children, kids.length > 0, expanded) } - for (const root of g.roots) walk(root, 0) + return g.roots.map(walk).filter((n): n is SessionNode => n !== null) } /** Matched sessions plus their ancestor chains (forced visible under search). */ @@ -186,66 +207,98 @@ function searchVisible(g: Group, q: string): Set { return visible } -function flattenSearch(g: Group, visible: ReadonlySet, rows: SidebarRow[]): void { +function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { const visited = new Set() - const walk = (id: SessionId, depth: number): void => { - if (visited.has(id) || !visible.has(id)) return + const walk = (id: SessionId): SessionNode | null => { + if (visited.has(id) || !visible.has(id)) return null visited.add(id) const s = g.summaries.get(id) /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return + if (s === undefined) return null const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid)) - rows.push(sessionRow(g, s, depth, kids.length > 0, kids.length > 0)) - for (const kid of kids) walk(kid, depth + 1) + const children = kids.map(walk).filter((n): n is SessionNode => n !== null) + return sessionNode(s, children, kids.length > 0, kids.length > 0) } - for (const root of g.roots) walk(root, 0) + return g.roots.map(walk).filter((n): n is SessionNode => n !== null) } /** - * Derive the flat sidebar row list. + * Derive the nested sidebar group structure. * - * Normal mode: every project row shows; sessions show under expanded - * projects, descending only into expanded sessions. Search mode (non-blank - * query, case-insensitive display-title substring): expansion state is ignored — + * Normal mode: every group shows; sessions populate under expanded groups, + * descending only into expanded sessions. A frontend Session Intent targeting + * a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query, + * case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups - * without a display-title or label hit are dropped, and a label-only hit keeps the - * bare project row. - * @param list - sessions list snapshot. + * without a display-title or label hit are dropped, a label-only hit keeps + * the bare group header, and Intent rows do not participate. + * @param list - sessions list snapshot (`current` feeds containsCurrent). + * @param workspaces - real workspaces in stable Host order. * @param view - local expansion arrays and search query. - * @returns rows in render order. + * @returns group sections in render order. */ -export function deriveRows(list: SessionListState, view: TreeView): SidebarRow[] { +export function deriveGroups( + list: SessionListState, + workspaces: readonly WorkspaceView[], + view: TreeView, +): GroupNode[] { const q = view.query.trim().toLowerCase() const expandedProjects = new Set(view.expandedProjects) const expandedSessions = new Set(view.expandedSessions) - const rows: SidebarRow[] = [] - for (const g of groupByCwd(list)) { + const intent = list.intent + const intentWorkspaceId = intent?.target.kind === 'workspace' + ? intent.target.workspaceId + : undefined + const currentAccount = list.current === undefined + ? undefined + : workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined + const currentGroup = list.current === undefined + ? undefined + : intent?.sessionId === list.current + ? intentWorkspaceId + : currentAccount ?? UNGROUPED_KEY + const groups: GroupNode[] = [] + for (const g of groupByWorkspace(list, workspaces)) { + const hasIntent = intentWorkspaceId !== undefined + && g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId + const intentHere = q === '' && hasIntent if (q === '') { - const expanded = expandedProjects.has(g.key) - rows.push({ type: 'project', key: g.key, cwd: g.cwd, label: g.label, sessionCount: g.summaries.size, expanded }) - if (expanded) flattenVisible(g, expandedSessions, rows) + const expanded = intentHere || expandedProjects.has(g.key) + groups.push({ + key: g.key, + workspaceId: g.workspaceId, + cwd: g.cwd, + label: g.label, + sessionCount: g.summaries.size + (hasIntent ? 1 : 0), + expanded, + containsCurrent: g.key === currentGroup, + intentHere, + sessions: expanded ? buildVisible(g, expandedSessions) : [], + }) } else { const visible = searchVisible(g, q) if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue - rows.push({ - type: 'project', + groups.push({ key: g.key, + workspaceId: g.workspaceId, cwd: g.cwd, label: g.label, - sessionCount: g.summaries.size, + sessionCount: g.summaries.size + (hasIntent ? 1 : 0), expanded: visible.size > 0, + containsCurrent: g.key === currentGroup, + intentHere: false, + sessions: buildSearch(g, visible), }) - flattenSearch(g, visible, rows) } } - return rows + return groups } /** - * Relative time label for session rows (figma samples: now / 2min / 1h / 2d / 18d / 2mo). - * @param updatedAt - epoch ms of the last update. - * @param now - current epoch ms. - * @returns compact age label. + * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). + * @param updatedAt - epoch ms of the session's last activity. + * @param now - current epoch ms (injected for pure rendering). + * @returns the row's trailing time label. */ export function formatRelativeTime(updatedAt: number, now: number): string { const MIN = 60_000 diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 51976d95bf..5d285bd20c 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -1,117 +1,60 @@ -/** - * apply wiring on a real cordis Context + SlotsService (terminal register - * form): SidebarRoot registered into the layout-declared sidebar slot, the - * thin inject surface (three plain service callbacks closed over the plugin - * ctx — no hooks, no store lines), load-order fail-loud, and fiber-teardown - * unregistration. Component behavior is covered props-direct in - * sidebar-root.spec.tsx; no renderer machinery here. - */ +/** Sidebar slot registration and its plain runtime/layout callbacks. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client' -// Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks. -import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -const sid = (s: string) => s as SessionId - -async function bench() { +async function bench(declare = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() - const list = createSnapshotStore({ - ids: [sid('a')], - byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, - current: undefined, - }) - const sessions = { - list, - create: vi.fn(async () => sid('minted')), - open: vi.fn(), - clear: vi.fn(), - } const layout = { toggleSidebar: vi.fn() } - ctx.provide('sessions', sessions) + const sessions = { open: vi.fn() } + const workspaces = { startSession: vi.fn() } ctx.provide('layout', layout) + ctx.provide('sessions', sessions as never) + ctx.provide('workspaces', workspaces as never) const slots = ctx.get('slots') as SlotsService - // The sidebar slot exists only while its declaring entry is live. - slots.register( - { name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never, - () => null, - ) - return { ctx, slots, sessions, layout } + if (declare) { + slots.register( + { name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never, + () => null, + ) + } + return { ctx, slots, layout, sessions, workspaces } } -/** The sidebar entry's injected share, read off the stored entry. */ -function injectedOf(slots: SlotsService): SidebarRootInjected { - const entries = slots.entries('sidebar') - expect(entries).toHaveLength(1) - // The typed StoredEntry.inject is declaration-derived ((...args: never[]) - // shape); the sidebar factory is parameterless, so the call is safe here. - const inject = entries[0]!.inject as (() => SidebarRootInjected) | undefined - return inject!() -} - -describe('apply', () => { - it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'layout', 'sessions']) +describe('ui-sidebar apply', () => { + it('declares only the services it uses', () => { + expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces']) }) - it('fails loud when mounted without the inject declaration', async () => { - // ctx.slots rides the cordis property proxy: reading it from a plugin - // that never declared the dependency throws instead of yielding undefined. - const ctx = new Context() - await ctx.plugin(SlotsService).await() - await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/) + it('registers the sidebar and declares its Workspace picker hole', async () => { + const b = await bench() + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('sidebar')).toHaveLength(1) + expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' }) + const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() + expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar']) + injected.startSession('workspace' as never, 'prompt') + expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt') + injected.open('session' as never) + expect(b.sessions.open).toHaveBeenCalledWith('session') + injected.toggleSidebar() + expect(b.layout.toggleSidebar).toHaveBeenCalledOnce() }) - it('fails loud when no live entry has declared the sidebar slot', async () => { - const ctx = new Context() - await ctx.plugin(SlotsService).await() - ctx.provide('sessions', {}) - ctx.provide('layout', {}) - await expect(ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/slot "sidebar" is not declared/) + it('fails when no live owner declared the sidebar slot', async () => { + const b = await bench(false) + await expect(b.ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/not declared/) }) - it('registers SidebarRoot with the thin three-callback inject surface', async () => { - const { ctx, slots } = await bench() - await ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(slots) - // The whole business face: three plain callbacks, no hooks, no store lines. - expect(Object.keys(injected).sort()).toEqual(['onCreate', 'onOpen', 'onToggleSidebar']) - }) - - it('routes the callbacks to the layout/sessions services', async () => { - const { ctx, slots, sessions, layout } = await bench() - await ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(slots) - - injected.onToggleSidebar() - expect(layout.toggleSidebar).toHaveBeenCalledOnce() - - injected.onOpen(sid('a')) - expect(sessions.open).toHaveBeenCalledWith('a') - - injected.onCreate() - expect(sessions.clear).toHaveBeenCalledOnce() - expect(sessions.create).not.toHaveBeenCalled() - - injected.onCreate('/proj') - expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' }) - // create-then-open lands after the create promise resolves. - await Promise.resolve() - await Promise.resolve() - expect(sessions.open).toHaveBeenCalledWith('minted') - }) - - it('teardown unregisters the slot entry', async () => { - const { ctx, slots } = await bench() - const fiber = ctx.plugin({ inject: [...inject], apply }) + it('removes the entry and child declaration on teardown', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(slots.entries('sidebar')).toHaveLength(1) await fiber.dispose() - expect(slots.entries('sidebar')).toHaveLength(0) + expect(b.slots.entries('sidebar')).toHaveLength(0) + expect(b.slots.spec('sidebar.workspace')).toBeUndefined() }) }) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index a76b86c106..bae2ed69ee 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -1,292 +1,82 @@ // @vitest-environment jsdom -/** - * SidebarRoot interaction spec, props-direct (slot-parity test doctrine: - * components are fed composed props, no assembly machinery). The standard - * useSessions hook is stubbed with a real web-react SnapshotStore selector; - * expansion/search live inside the component, so all viewing behavior is - * driven through the DOM. Covers expand/collapse, subtree unfold, search - * filtering, row activation, and the creation entries. - */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { act, useSyncExternalStore } from 'react' -// Runtime is React-free, so the spec binds its selector locally. -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import type { + SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' -/** Minimal selector hook over an engine store (production binding lives in the renderer). */ -function hookOf(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) { - return (sel: (s: T) => S, _eq?: (a: S, b: S) => boolean): S => - sel(useSyncExternalStore(src.subscribe.bind(src), src.getSnapshot.bind(src))) -} - -const sid = (s: string) => s as SessionId - -/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */ -interface SummaryInit { - id: string - title?: string - cwd?: string - parentId?: string - running?: boolean - updatedAt?: number -} - -function summary(init: SummaryInit): SessionSummary { - const s: SessionSummary = { - id: sid(init.id), - title: init.title ?? init.id, - displayTitle: init.title ?? init.id, - running: init.running ?? false, - updatedAt: init.updatedAt ?? 0, - } - if (init.cwd !== undefined) s.cwd = init.cwd - if (init.parentId !== undefined) s.parentId = sid(init.parentId) - return s -} - -function listStateOf(...summaries: SessionSummary[]): SessionListState { - const byId: Record = {} - for (const s of summaries) byId[s.id] = s - return { ids: summaries.map((s) => s.id), byId, current: undefined } -} - afterEach(cleanup) - -function mount(...summaries: SessionSummary[]) { - // Real engine store as the useSessions stub: same uSES selector shape the - // framework delivers, so list updates re-render exactly like production. - const sessions = createSnapshotStore(listStateOf(...summaries)) - const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) }) - const onCreate = vi.fn() - // The owner decides collapsed in production (AppFrame maps the preference); - // the harness mirrors that loop so the toggle drives a re-render. - let collapsed = false - const view = (width: number) => ( - - ) - const onToggleSidebar = vi.fn(() => { - collapsed = !collapsed - utils.rerender(view(collapsed ? 56 : 300)) - }) - const utils = render(view(300)) - return { sessions, onOpen, onCreate, onToggleSidebar, ...utils } +const sid = (id: string) => id as SessionId +const wid = (id: string) => id as WorkspaceId +const hook = (snapshot: T) => (selector: (state: T) => S): S => selector(snapshot) +const workspace: WorkspaceView = { + workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')], + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +} +const sessions: SessionListState = { + ids: [sid('s1')], + byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } }, + current: undefined, phase: 'ready', + intent: undefined, +} +const workspaces: WorkspaceListState = { + items: [workspace], state: 'idle', phase: 'ready', error: null, + intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId, } -const projectData = () => [ - summary({ id: 'root', title: 'root work', cwd: '/proj', updatedAt: 5 }), - summary({ id: 'kid', title: 'forked child', cwd: '/proj', parentId: sid('root'), updatedAt: 4 }), - summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }), -] - -/** Flush the store's microtask-batched notification into React. */ -const flush = async () => { await act(async () => { await Promise.resolve() }) } - -/** The brand wordmark is decorative svg (aria-hidden, no text); locate it by its native viewBox. */ -const wordmark = () => document.querySelector('svg[viewBox="0 0 182 24"]') +function mount(sessionState: SessionListState = sessions) { + const startSession = vi.fn() + const open = vi.fn() + let pickerOwner: unknown + const view = render( + { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + />, + ) + return { view, startSession, open, pickerOwner: () => pickerOwner } +} describe('SidebarRoot', () => { - it('renders chrome and collapsed project rows', () => { - mount(...projectData()) - expect(wordmark()).not.toBeNull() - expect(screen.getByText('New Session')).toBeTruthy() - expect(screen.getByText('proj')).toBeTruthy() - expect(screen.getByText('2 sessions')).toBeTruthy() - expect(screen.getByText('1 session')).toBeTruthy() - expect(screen.queryByText('root work')).toBeNull() + it('renders real Workspaces from useWorkspaces and routes New Session', () => { + const b = mount() + expect(screen.getByText('Project')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'New session' })) + expect(b.startSession).toHaveBeenCalledWith() }) - it('expands a project on click and unfolds a subtree via the twist', () => { - mount(...projectData()) - act(() => { fireEvent.click(screen.getByText('proj')) }) - expect(screen.getByText('root work')).toBeTruthy() - expect(screen.queryByText('forked child')).toBeNull() - act(() => { fireEvent.click(screen.getByLabelText('Expand')) }) - expect(screen.getByText('forked child')).toBeTruthy() - act(() => { fireEvent.click(screen.getByLabelText('Collapse')) }) - expect(screen.queryByText('forked child')).toBeNull() - }) - - it('opens a session on row click and marks it selected', async () => { - const { onOpen } = mount(...projectData()) - act(() => { fireEvent.click(screen.getByText('proj')) }) - act(() => { fireEvent.click(screen.getByText('root work')) }) - expect(onOpen).toHaveBeenCalledWith('root') - // The mock routed the open into sessions.current — highlight follows. - await flush() - expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true') - }) - - it('search filters across groups and forces ancestor chains visible', () => { - mount(...projectData()) - const input = screen.getByPlaceholderText('Search name, keywords...') - act(() => { fireEvent.change(input, { target: { value: 'forked' } }) }) - expect(screen.getByText('forked child')).toBeTruthy() - expect(screen.getByText('root work')).toBeTruthy() - expect(screen.queryByText('elsewhere')).toBeNull() - expect(screen.queryByText(/^other$/)).toBeNull() - act(() => { fireEvent.click(screen.getByLabelText('Clear search')) }) - expect(screen.queryByText('root work')).toBeNull() - expect(screen.getByText('proj')).toBeTruthy() - }) - - it('shows the blank-list empty state without a query', () => { - mount() - expect(screen.getByText('No sessions yet')).toBeTruthy() - }) - - it('shows the no-match empty state', () => { - mount(...projectData()) - const input = screen.getByPlaceholderText('Search name, keywords...') - act(() => { fireEvent.change(input, { target: { value: 'zzz-none' } }) }) - expect(screen.getByText('No matches')).toBeTruthy() - }) - - it('routes the three creation entries with the right cwd', () => { - const { onCreate } = mount(...projectData()) - act(() => { fireEvent.click(screen.getByText('New Session')) }) - expect(onCreate).toHaveBeenLastCalledWith() - act(() => { fireEvent.click(screen.getByLabelText('New workspace')) }) - expect(onCreate).toHaveBeenLastCalledWith() - // Per-project "+" is hover-revealed by CSS; still clickable in jsdom. - act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) }) - expect(onCreate).toHaveBeenLastCalledWith('/proj') - }) - - it('collapse fades the wide content out, then the rail keeps the four controls', () => { - vi.useFakeTimers() - try { - const { onToggleSidebar, onCreate } = mount(...projectData()) - act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) - expect(onToggleSidebar).toHaveBeenCalledOnce() - // Fade window: the wide chrome is still mounted while it fades. - expect(wordmark()).not.toBeNull() - expect(screen.getByRole('tree')).toBeTruthy() - // Settle: wide content unmounts, the rail controls remain. - act(() => { vi.advanceTimersByTime(300) }) - expect(wordmark()).toBeNull() - expect(screen.queryByText('New Session')).toBeNull() - expect(screen.queryByRole('tree')).toBeNull() - // Rail order mirrors the expanded rows: open, new session, new workspace, search. - const rail = ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings'] - .map((label) => screen.getByLabelText(label)) - for (let i = 1; i < rail.length; i++) { - expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - } - // Rail creation entries route like their expanded counterparts. - act(() => { fireEvent.click(screen.getByLabelText('New session')) }) - expect(onCreate).toHaveBeenLastCalledWith() - act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) }) - expect(onToggleSidebar).toHaveBeenCalledTimes(2) - expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy() - expect(screen.getByText('New Session')).toBeTruthy() - } finally { - vi.useRealTimers() - } - }) - - it('rail search expands the sidebar and focuses the search box', () => { - vi.useFakeTimers() - try { - const { onToggleSidebar } = mount(...projectData()) - // While expanded the search control is inert (the row click focuses instead). - act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) - expect(onToggleSidebar).not.toHaveBeenCalled() - act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) - act(() => { vi.advanceTimersByTime(300) }) - act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) - expect(onToggleSidebar).toHaveBeenCalledTimes(2) - // Focus waits out the 300ms column slide (EXPAND_SLIDE_MS). - act(() => { vi.advanceTimersByTime(300) }) - const input = screen.getByPlaceholderText('Search name, keywords...') - expect(document.activeElement).toBe(input) - } finally { - vi.useRealTimers() - } - }) - - it('expanded search focuses without toggling the sidebar', () => { - const { onToggleSidebar } = mount(...projectData()) - const input = screen.getByPlaceholderText('Search name, keywords...') - act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) - expect(document.activeElement).toBe(input) - expect(onToggleSidebar).not.toHaveBeenCalled() - }) - - it('the search query survives a collapse/expand round trip', () => { - vi.useFakeTimers() - try { - mount(...projectData()) - const input = screen.getByPlaceholderText('Search name, keywords...') - act(() => { fireEvent.change(input, { target: { value: 'forked' } }) }) - act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) - act(() => { vi.advanceTimersByTime(300) }) - act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) }) - const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement - expect(restored.value).toBe('forked') - expect(screen.getByText('forked child')).toBeTruthy() - expect(screen.queryByText('elsewhere')).toBeNull() - } finally { - vi.useRealTimers() - } - }) - - it('group-by menu behaves', () => { - mount(...projectData()) - expect(screen.queryByText('Update')).toBeNull() - act(() => { fireEvent.click(screen.getByLabelText('Group by')) }) - expect(screen.getByText('Update')).toBeTruthy() - expect(screen.getByText('Status')).toBeTruthy() - // Selecting the active strategy closes the list (only workspace is enabled). - act(() => { fireEvent.click(screen.getByText('WorkSpace', { selector: 'button *' })) }) - expect(screen.queryByText('Update')).toBeNull() - // Reopen and dismiss via Escape (Menu onClose channel). - act(() => { fireEvent.click(screen.getByLabelText('Group by')) }) - act(() => { fireEvent.keyDown(document, { key: 'Escape' }) }) - expect(screen.queryByText('Update')).toBeNull() - }) - - it('re-renders when the sessions list gains a session', async () => { - const { sessions } = mount(...projectData()) - act(() => { - sessions.update((draft) => { - draft.ids.push(sid('fresh')) - draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 }) - }) + it('shows a frontend Session under its real Workspace and routes its row plus', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const } + const b = mount({ + ...sessions, + current: intent.sessionId, + intent, }) - // Store notifications are microtask-batched. - await flush() - expect(screen.getByText('fresh')).toBeTruthy() + expect(screen.getByText('New session')).toBeTruthy() + expect(screen.getByText('2 sessions')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'New session in Project' })) + expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) }) - it('row "More" anchors swallow the click without opening or toggling', () => { - const { onOpen } = mount(...projectData()) - act(() => { fireEvent.click(screen.getByText('proj')) }) - // Project-row anchor: must not collapse the project (rows stay visible). - act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) }) - expect(screen.getByText('root work')).toBeTruthy() - // Session-row anchor: must not open the session. - act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) }) - expect(onOpen).not.toHaveBeenCalled() + it('forwards Workspace picker selection and closes the picker', () => { + const b = mount() + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void } + expect(owner.open).toBe(true) + owner.onPick(workspace.workspaceId) + expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) }) - it('shows the running state dot only for running sessions', () => { - mount( - summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 2 }), - summary({ id: 'idle', title: 'idle one', cwd: '/p', updatedAt: 1 }), - ) - act(() => { fireEvent.click(screen.getByText('p')) }) - const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')! - const idleRow = screen.getByText('idle one').closest('[role="treeitem"]')! - expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy() - expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull() + it('opens a real Session through the owner action', () => { + const b = mount({ ...sessions, current: sid('intent'), intent: { + sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready', + } }) + fireEvent.click(screen.getByText('Project')) + fireEvent.click(screen.getByText('First session')) + expect(b.open).toHaveBeenCalledWith(sid('s1')) }) }) diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-sidebar/tests/tree.spec.ts index 037a2a82ac..76d68db4ee 100644 --- a/packages/client/ui-sidebar/tests/tree.spec.ts +++ b/packages/client/ui-sidebar/tests/tree.spec.ts @@ -1,245 +1,74 @@ import { describe, expect, it } from 'vitest' -import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' -import { - deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL, - type SessionRow, type TreeView, -} from '../src/client/tree.ts' +import type { + SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import { deriveGroups, formatRelativeTime, UNGROUPED_KEY } from '../src/client/tree.ts' -const sid = (s: string) => s as SessionId - -/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */ -interface SummaryInit { - id: string - title?: string - displayTitle?: string - cwd?: string - parentId?: string - running?: boolean - updatedAt?: number -} - -function summary(init: SummaryInit): SessionSummary { - const s: SessionSummary = { - id: sid(init.id), - displayTitle: init.displayTitle ?? init.title ?? init.id, - running: init.running ?? false, - updatedAt: init.updatedAt ?? 0, - } - if (init.title !== undefined) s.title = init.title - if (init.cwd !== undefined) s.cwd = init.cwd - if (init.parentId !== undefined) s.parentId = sid(init.parentId) - return s -} - -function listOf(...summaries: SessionSummary[]): SessionListState { - const byId: Record = {} - for (const s of summaries) byId[s.id] = s - return { ids: summaries.map(s => s.id), byId, current: undefined } -} - -const view = (partial: Partial = {}): TreeView => ({ - expandedProjects: partial.expandedProjects ?? [], - expandedSessions: partial.expandedSessions ?? [], - query: partial.query ?? '', +const sid = (id: string) => id as SessionId +const wid = (id: string) => id as WorkspaceId +const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ + id: sid(id), displayTitle: id, running: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), +}) +const list = (...items: SessionSummary[]): SessionListState => ({ + ids: items.map(item => item.id), + byId: Object.fromEntries(items.map(item => [item.id, item])), + current: undefined, + phase: 'ready', + intent: undefined, +}) +const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({ + workspaceId: wid(id), path: `/projects/${id}`, title: id, + sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +}) +const view = (expandedProjects: readonly string[] = [], query = '') => ({ + expandedProjects, expandedSessions: [] as string[], query, }) -describe('projectLabel', () => { - it('takes the basename and survives trailing separators', () => { - expect(projectLabel('/home/me/proj')).toBe('proj') - expect(projectLabel('/home/me/proj/')).toBe('proj') - expect(projectLabel('C:\\work\\thing')).toBe('thing') +describe('deriveGroups', () => { + it('keeps Host Workspace and sessionIds order without Client recency sorting', () => { + const sessions = list(summary('newer', 20), summary('older', 10)) + const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])] + const groups = deriveGroups(sessions, workspaces, view(['first'])) + expect(groups.map(group => group.key)).toEqual(['first', 'empty']) + expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) - it('falls back for empty and root-only paths', () => { - expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL) - expect(projectLabel('')).toBe(UNGROUPED_LABEL) - expect(projectLabel('///')).toBe('///') - }) -}) - -describe('deriveRows grouping', () => { - it('groups by cwd into project rows with counts, newest group first', () => { - const rows = deriveRows(listOf( - summary({ id: 'a', cwd: '/x/alpha', updatedAt: 10 }), - summary({ id: 'b', cwd: '/x/beta', updatedAt: 30 }), - summary({ id: 'c', cwd: '/x/alpha', updatedAt: 20 }), - ), view()) - expect(rows).toEqual([ - expect.objectContaining({ type: 'project', key: '/x/beta', label: 'beta', sessionCount: 1, expanded: false }), - expect.objectContaining({ type: 'project', key: '/x/alpha', label: 'alpha', sessionCount: 2 }), - ]) + it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { + const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) + const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY])) + expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY]) + expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) }) - it('orders equally-recent groups by label and skips ids missing from byId', () => { - const list = listOf( - summary({ id: 'b1', cwd: '/x/beta', updatedAt: 5 }), - summary({ id: 'a1', cwd: '/x/alpha', updatedAt: 5 }), - // Same basename and same recency as beta: label comparator returns 0, - // insertion order breaks the tie. - summary({ id: 'b2', cwd: '/y/beta', updatedAt: 5 }), - ) - list.ids.push(sid('ghost')) - const rows = deriveRows(list, view()) - expect(rows.map(r => r.type === 'project' && r.key)).toEqual(['/x/alpha', '/x/beta', '/y/beta']) - }) - - it('buckets cwd-less sessions under the ungrouped project row', () => { - const rows = deriveRows(listOf(summary({ id: 'a' })), view()) - expect(rows).toEqual([ - expect.objectContaining({ type: 'project', key: UNGROUPED_KEY, cwd: undefined, label: UNGROUPED_LABEL }), - ]) - }) - - it('hides sessions under collapsed projects and shows them when expanded', () => { - const list = listOf( - summary({ id: 'a', cwd: '/p', updatedAt: 1 }), - summary({ id: 'b', cwd: '/p', updatedAt: 2 }), - ) - expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0) - const rows = deriveRows(list, view({ expandedProjects: ['/p'] })) - expect(rows.slice(1)).toEqual([ - expect.objectContaining({ type: 'session', id: 'b', depth: 0 }), - expect.objectContaining({ type: 'session', id: 'a', depth: 0 }), - ]) - }) -}) - -describe('deriveRows session tree', () => { - const treeList = listOf( - summary({ id: 'root', cwd: '/p', updatedAt: 5 }), - summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 4 }), - summary({ id: 'grandkid', cwd: '/p', parentId: sid('kid'), updatedAt: 3 }), - summary({ id: 'other', cwd: '/p', updatedAt: 9 }), - ) - - it('nests children under expanded parents with increasing depth', () => { - const rows = deriveRows(treeList, view({ - expandedProjects: ['/p'], - expandedSessions: ['root', 'kid'], + it('shows one frontend Session row only under a real target Workspace', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const } + const target = workspace('first', []) + expect(deriveGroups({ ...list(), current: intent.sessionId, intent }, [target], view())[0]).toEqual(expect.objectContaining({ + intentHere: true, + sessionCount: 1, + containsCurrent: true, })) - expect(rows.slice(1)).toEqual([ - expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }), - expect.objectContaining({ id: 'root', depth: 0, hasChildren: true, expanded: true }), - expect.objectContaining({ id: 'kid', depth: 1, hasChildren: true, expanded: true }), - expect.objectContaining({ id: 'grandkid', depth: 2, hasChildren: false }), - ]) + const hiddenIntent = { sessionId: sid('zero'), target: { kind: 'workspace-intent' as const }, prompt: '', phase: 'ready' as const } + expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false) }) - it('collapses subtrees at unexpanded sessions', () => { - const rows = deriveRows(treeList, view({ expandedProjects: ['/p'] })) - const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id) - expect(ids).toEqual(['other', 'root']) - }) - - it('degrades a cross-group parent link to a group root', () => { - const rows = deriveRows(listOf( - summary({ id: 'p1', cwd: '/a', updatedAt: 2 }), - summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }), - ), view({ expandedProjects: ['/a', '/b'] })) - expect(rows).toEqual([ - expect.objectContaining({ type: 'project', key: '/a' }), - expect.objectContaining({ id: 'p1', depth: 0 }), - expect.objectContaining({ type: 'project', key: '/b' }), - expect.objectContaining({ id: 'stray', depth: 0 }), - ]) - }) - - it('keeps cycle members visible as extra roots without looping', () => { - const rows = deriveRows(listOf( - summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }), - summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }), - summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }), - ), view({ expandedProjects: ['/p'], expandedSessions: ['x', 'y', 'self'] })) - const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id) - expect(ids).toContain('self') - expect(ids).toContain('x') - expect(ids).toContain('y') - expect(ids).toHaveLength(3) - }) - - it('breaks updatedAt ties deterministically by id', () => { - const rows = deriveRows(listOf( - summary({ id: 'b', cwd: '/p', updatedAt: 7 }), - summary({ id: 'a', cwd: '/p', updatedAt: 7 }), - summary({ id: 'c', cwd: '/p', updatedAt: 7 }), - ), view({ expandedProjects: ['/p'] })) - const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id) - expect(ids).toEqual(['a', 'b', 'c']) - }) - - it('collects multiple children under one parent in recency order', () => { - const rows = deriveRows(listOf( - summary({ id: 'p', cwd: '/p', updatedAt: 9 }), - summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }), - summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }), - ), view({ expandedProjects: ['/p'], expandedSessions: ['p'] })) - const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id) - expect(ids).toEqual(['p', 'new', 'old']) - }) - - it('carries the running flag onto rows', () => { - const rows = deriveRows( - listOf(summary({ id: 'a', cwd: '/p', running: true })), - view({ expandedProjects: ['/p'] })) - expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true })) - }) -}) - -describe('deriveRows search', () => { - const list = listOf( - summary({ id: 'root', title: 'alpha work', cwd: '/p', updatedAt: 5 }), - summary({ id: 'kid', title: 'deep needle here', cwd: '/p', parentId: sid('root'), updatedAt: 4 }), - summary({ id: 'noise', title: 'unrelated', cwd: '/p', updatedAt: 3 }), - summary({ id: 'q', title: 'quiet', cwd: '/other', updatedAt: 2 }), - ) - - it('forces matched sessions and their ancestor chains visible, ignoring expansion', () => { - const rows = deriveRows(list, view({ query: 'NEEDLE' })) - expect(rows).toEqual([ - expect.objectContaining({ type: 'project', key: '/p', expanded: true }), - expect.objectContaining({ id: 'root', depth: 0, expanded: true }), - expect.objectContaining({ id: 'kid', depth: 1 }), - ]) - }) - - it('drops groups without a hit and keeps a bare project row on label-only hits', () => { - const rows = deriveRows(list, view({ query: 'other' })) - expect(rows).toEqual([ - expect.objectContaining({ type: 'project', key: '/other', expanded: false }), - ]) - }) - - it('blank query means normal mode', () => { - const rows = deriveRows(list, view({ query: ' ' })) - expect(rows.every(r => r.type === 'project')).toBe(true) - }) - - it('matches the effective display title when no durable title is available', () => { - const fallback = listOf(summary({ id: 'raw-id', displayTitle: 'project fallback', cwd: '/elsewhere' })) - const rows = deriveRows(fallback, view({ query: 'fallback' })) - expect(rows).toEqual([ - expect.objectContaining({ type: 'project', key: '/elsewhere' }), - expect.objectContaining({ type: 'session', id: 'raw-id', title: 'project fallback' }), - ]) + it('search filters real Sessions and omits the Intent placeholder', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const } + const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match')) + expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('match')]) + expect(groups[0]!.intentHere).toBe(false) + expect(groups[0]!.sessionCount).toBe(2) }) }) describe('formatRelativeTime', () => { - const now = 1_000_000_000_000 - it.each([ - [now, 'now'], - [now - 30_000, 'now'], - [now - 2 * 60_000, '2min'], - [now - 3_600_000, '1h'], - [now - 2 * 86_400_000, '2d'], - [now - 18 * 86_400_000, '18d'], - [now - 65 * 86_400_000, '2mo'], - [now - 400 * 86_400_000, '1y'], - ])('%d -> %s', (at, label) => { - expect(formatRelativeTime(at, now)).toBe(label) - }) - - it('clamps future timestamps to now', () => { - expect(formatRelativeTime(now + 5_000, now)).toBe('now') + it('formats current, minute, hour, day, month, and year buckets', () => { + const now = 400 * 24 * 60 * 60 * 1_000 + expect(formatRelativeTime(now, now)).toBe('now') + expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5min') + expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe('3h') + expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2d') + expect(formatRelativeTime(now - 60 * 86_400_000, now)).toBe('2mo') + expect(formatRelativeTime(0, now)).toBe('1y') }) }) diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index 16d6380639..4057691295 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -13,7 +13,7 @@ One `register({ name, children?, store?, inject?, ...kind }, Component)` call co Chain-kind slots invert keyed routing — entries self-nominate instead of the dispatch site picking an `entryKey`: each registration carries a pure `ChainSelect` selector (plus optional ascending `priority`, ties in registration order), the first non-null return elects its entry and becomes the component's `matched` prop, and all-null falls to the owner's `renderSlotChain` fallback (`ChainRenderOpts`). -The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx. +The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). The renderer binds the runtime's session and workspace observable sources into selector hooks. Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx. The store family (`defineStore` spec in / `StoreHandle` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here. diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index e6a85664b0..677bc4e0df 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -74,8 +74,8 @@ export interface SessionStandardProps {} /** * Framework standard kit delivered to EVERY slot component (the global seat). - * Declared empty here; the runtime package merges `useSessions` (the session - * list selector hook — the sidebar tree's single derivation source). + * Declared empty here; the runtime package merges the global object-layer + * selector hooks that shared page composition consumes. */ export interface GlobalStandardProps {} diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 49d64205c7..058929b1ff 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -98,6 +98,11 @@ export interface SlotRendererHost { */ cell(id: string): SessionCell | undefined } + /** Workspace-side standard-kit sources. */ + workspaces: { + /** Workspace list source backing the useWorkspaces standard hook. */ + list: HostObservable + } } /** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */ diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index dcc1ba6855..c1e6331ef6 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -15,7 +15,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' // Export discipline: packages/client/AGENTS.md. import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx' @@ -62,7 +62,15 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) { /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined } as SessionListState) + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} + +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + recentWorkspaceId: undefined, + }) return bindSnapshotSelector(store) } @@ -75,6 +83,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { sessionId: SID, useSession: fakeSession(nodes).useSession, useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), } as unknown as ConvViewProps } @@ -121,7 +130,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES const View = entry.component as FC return ( ) @@ -131,6 +140,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES sessionId={SID} useSession={useSession} useSessions={emptySessions()} + useWorkspaces={emptyWorkspaces()} useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot} @@ -144,6 +154,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES send={vi.fn()} stop={vi.fn()} open={vi.fn()} + updateSessionPrompt={vi.fn()} + retrySessionPrompt={vi.fn()} />, ) } diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md new file mode 100644 index 0000000000..b2a024fc5e --- /dev/null +++ b/packages/client/ui-workspace/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-workspace + +Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals. + +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. + +Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. + +## Model Experience + +None, as the picker is browser chrome; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **No Workspace rename/delete controls** — the picker supports selection and creation only. +- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal. diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json new file mode 100644 index 0000000000..0a6c82c0cf --- /dev/null +++ b/packages/client/ui-workspace/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-workspace", + "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-sidebar" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.module.css b/packages/client/ui-workspace/src/client/WorkspacePicker.module.css new file mode 100644 index 0000000000..e439d30f27 --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.module.css @@ -0,0 +1,46 @@ +/* Modal form styles mirror the empty state's path/create modals (same figma + * dialog family: field h44, r22, hairline border, pad 14/7) so the two + * entries stay visually identical. */ +.modalInput { + box-sizing: border-box; + width: 100%; + height: 44px; + padding: 7px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; + outline: none; + background: transparent; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.modalInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +.modalInput:disabled { + color: var(--dsw-alias-label-dimmed); +} + +.modalAction { + min-width: 72px; +} + +.modalError, +.modalStatus, +.menuStatus { + margin-top: 8px; + font-size: 12px; + line-height: 18px; +} + +.modalError { + color: var(--dsw-alias-state-error-primary); +} + +.modalStatus, +.menuStatus { + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx new file mode 100644 index 0000000000..8f6f483580 --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -0,0 +1,196 @@ +/** Shared Workspace picker for the sidebar and New Session hero. */ +import { useCallback, useState } from 'react' +import { + Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspacePickerProps } from './contract/slots.ts' +import css from './WorkspacePicker.module.css' + +const CREATE_WORKSPACE = '::create-workspace' +const USE_EXISTING = '::use-existing' +const CREATE_NEW = '::create-new' + +type ModalKind = 'path' | 'create' | null + +export function WorkspacePicker({ + open, + anchorRef, + useWorkspaces, + onPick, + onClose, + createWorkspace, +}: WorkspacePickerProps) { + const workspaceSnapshot = useWorkspaces(state => state) + const workspaces = workspaceSnapshot.items + const getAnchorRect = useCallback( + () => anchorRef?.current?.getBoundingClientRect() ?? null, + [anchorRef], + ) + const [modalKind, setModalKind] = useState(null) + const [pathDraft, setPathDraft] = useState('') + const [workspaceName, setWorkspaceName] = useState('') + const [creating, setCreating] = useState(false) + const [modalError, setModalError] = useState(null) + const normalizedWorkspaceName = workspaceName.trim() + const duplicateWorkspaceName = normalizedWorkspaceName !== '' + && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) + + const items: MenuEntry[] = [ + ...workspaces.map(workspace => ({ + id: workspace.workspaceId as string, + label: workspace.title, + icon: , + })), + ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), + { + id: CREATE_WORKSPACE, + label: 'Create workspace', + icon: , + submenu: [ + { id: USE_EXISTING, label: 'Use an existing folder' }, + { id: CREATE_NEW, label: 'Create a new workspace' }, + ], + }, + ] + + const closeModal = (): void => { + if (creating) return + setModalKind(null) + setModalError(null) + } + + const handleSelect = (id: string): void => { + if (id === USE_EXISTING) { + onClose() + setPathDraft('') + setModalError(null) + setModalKind('path') + return + } + if (id === CREATE_NEW) { + onClose() + setWorkspaceName('workspace') + setModalError(null) + setModalKind('create') + return + } + onPick(id as WorkspaceId) + } + + const create = (input: { name: string } | { path: string }): void => { + if (creating) return + setCreating(true) + setModalError(null) + void createWorkspace(input).then((workspace) => { + setCreating(false) + setModalKind(null) + onPick(workspace.workspaceId) + }).catch((reason: unknown) => { + const message = reason instanceof Error ? reason.message : String(reason) + setModalError(`Workspace creation failed: ${message}`) + setCreating(false) + }) + } + + const confirmPath = (): void => { + const path = pathDraft.trim() + if (path !== '') create({ path }) + } + + const confirmCreate = (): void => { + if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) { + create({ name: normalizedWorkspaceName }) + } + } + + return ( + <> + + {open && workspaceSnapshot.phase === 'pending' &&
Loading workspaces…
} + + + + + )} + > + { setPathDraft(event.target.value) }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + confirmPath() + } + }} + /> + {creating &&
Creating workspace…
} + {modalError !== null &&
{modalError}
} +
+ + + + + )} + > + { setWorkspaceName(event.target.value); setModalError(null) }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + confirmCreate() + } + }} + /> + {creating &&
Creating workspace…
} + {duplicateWorkspaceName && ( +
A workspace named “{normalizedWorkspaceName}” already exists.
+ )} + {modalError !== null &&
{modalError}
} +
+ + ) +} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts new file mode 100644 index 0000000000..a3045e5070 --- /dev/null +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -0,0 +1,30 @@ +/** + * Shared Workspace picker contract for the sidebar and page-local Session Intent hero + * slots. Each runtime share provides its owner's popover controls plus the + * global useWorkspaces hook; this package adds the injected Host Workspace + * creation callback. + */ +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pull both owner SlotMap merges into programs that resolve the +// picker runtime union below. +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' + +/** + * Registrant-private injected share. Pick semantics remain in each owner's + * onPick callback; this callback creates only the real Host Workspace. A type + * alias supplies the implicit index signature required by the registry. + */ +export type WorkspacePickerInjected = { + /** Explicitly create or adopt a real Workspace before targeting a Session. */ + createWorkspace(input: { name: string } | { path: string }): Promise +} + +/** + * Full picker props: either owner's runtime share, including useWorkspaces, + * plus this package's injected creation callback. + */ +export type WorkspacePickerProps = + (PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>) + & WorkspacePickerInjected diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts new file mode 100644 index 0000000000..aa54587650 --- /dev/null +++ b/packages/client/ui-workspace/src/client/index.ts @@ -0,0 +1,54 @@ +/** + * Shared Workspace picker plugin, browser half. WorkspacePicker registers in + * the sidebar and page-local Session Intent hero slots, reads real Host Workspaces + * through the global useWorkspaces hook, and delegates selection semantics to + * each owner. Its injected share creates a Workspace without creating a + * Session. Export discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspacePickerInjected } from './contract/slots.ts' +import { WorkspacePicker } from './WorkspacePicker.tsx' + +export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts' + +/** + * Required services (cordis fiber inject). The target slot is declared by + * the ui-sidebar apply, whose activation order relative to this one is NOT + * constrained: dshClient.inject edges are informational (loading/prefetch + * metadata, never apply sequencing) and the sidebar provides no waitable + * service. apply therefore registers via declaration-aware deferral instead + * of assuming order. + */ +export const inject = ['slots', 'workspaces'] + +/** + * Register WorkspacePicker in both owner slots once their declarations are on + * the ledger. The inject factory returns a plain Workspace creation callback; + * data reads use the framework's global useWorkspaces hook. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + const injected = (): WorkspacePickerInjected => ({ + createWorkspace: input => ctx.workspaces.create(input), + }) + // Declaration-aware registration: the sidebar's declaring apply may + // activate after this one (entry activation order is unconstrained), and a + // register into an undeclared slot throws. Register once the declaration + // is on the ledger; the subscription also re-registers after an HMR + // collapse re-declares the slot (the cascade disposed our entry with it). + ctx.effect(() => { + const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const + const disposers = new Map<(typeof slotNames)[number], () => void>() + const tryRegister = (name: (typeof slotNames)[number]): void => { + if (ctx.slots.spec(name) === undefined) return + if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return + disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker)) + } + const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) })) + for (const name of slotNames) tryRegister(name) + return () => { + for (const unsubscribe of unsubscribers) unsubscribe() + for (const dispose of disposers.values()) dispose() + } + }, 'ui-workspace: picker registrations') +} diff --git a/packages/client/ui-workspace/src/css-modules.d.ts b/packages/client/ui-workspace/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-workspace/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-workspace/src/index.ts b/packages/client/ui-workspace/src/index.ts new file mode 100644 index 0000000000..2af6a1023b --- /dev/null +++ b/packages/client/ui-workspace/src/index.ts @@ -0,0 +1,9 @@ +/** + * Workspace picker plugin, node half. Pure UI plugin: the empty apply exists + * so the plugin appears in the host cordis.yml / Loader (load and lifecycle + * follow the host; the browser half ships via exports["./client"], discovered + * through the package.json dshClient declaration). + */ + +/** Host plugin body — no host-side behavior for the workspace picker plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-workspace/src/invariant.ts b/packages/client/ui-workspace/src/invariant.ts new file mode 100644 index 0000000000..4d3a52353c --- /dev/null +++ b/packages/client/ui-workspace/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-workspace`. + * @module @deepseek-ai/dsh-client-ui-workspace/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workspace' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-workspace-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a pure-consumer plugin registering one presentational + * component into two host-declared slots — its inject face is two stateless + * RPC wrappers plus a create-and-open call; it emits no cordis events and + * owns no cross-plugin mutable state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts new file mode 100644 index 0000000000..9352caa0b4 --- /dev/null +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -0,0 +1,69 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const create = vi.fn(async (input: { name: string } | { path: string }) => ({ + workspaceId: 'ws-new' as never, + path: 'name' in input ? `/projects/${input.name}` : input.path, + title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', + })) + ctx.provide('workspaces', { create }) + return { ctx, slots: ctx.get('slots') as SlotsService, create } +} + +function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void { + return slots.register( + { name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never, + () => null, + ) +} + +function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected { + const entry = slots.entries(name)[0]! + return (entry.inject as () => WorkspacePickerInjected)() +} + +describe('ui-workspace apply', () => { + it('declares the independent Workspace service', () => { + expect(inject).toEqual(['slots', 'workspaces']) + }) + + it('registers the shared picker for declarations that arrive before or after apply', async () => { + const before = await bench() + declare(before.slots, 'sidebar.workspace') + await before.ctx.plugin({ inject: [...inject], apply }).await() + expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + declare(after.slots, 'conversation.empty.workspace') + await Promise.resolve() + expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker) + }) + + it('routes name and path creation to WorkspacesService', async () => { + const b = await bench() + declare(b.slots, 'sidebar.workspace') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected = injectedOf(b.slots, 'sidebar.workspace') + await injected.createWorkspace({ name: 'project' }) + await injected.createWorkspace({ path: '/tmp/project' }) + expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' }) + expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' }) + }) + + it('unregisters picker entries on teardown', async () => { + const b = await bench() + declare(b.slots, 'sidebar.workspace') + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await fiber.dispose() + expect(b.slots.entries('sidebar.workspace')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-workspace/tests/invariant.spec.ts b/packages/client/ui-workspace/tests/invariant.spec.ts new file mode 100644 index 0000000000..0606c94e09 --- /dev/null +++ b/packages/client/ui-workspace/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as WorkspaceInvariant from '@deepseek-ai/dsh-client-ui-workspace/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(WorkspaceInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-workspace') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx new file mode 100644 index 0000000000..32d5be145b --- /dev/null +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { + SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' + +afterEach(cleanup) + +const wid = (id: string) => id as WorkspaceId +function workspace(id: string, title = id): WorkspaceView { + return { + workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + } +} +const hook = (snapshot: T) => (selector: (state: T) => S): S => selector(snapshot) +const sessions: SessionListState = { + ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready', +} +const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ + items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + recentWorkspaceId: items[0]?.workspaceId, +}) +function anchor(): { current: HTMLElement } { + const element = document.createElement('button') + element.getBoundingClientRect = () => ({ + top: 10, left: 20, width: 30, height: 40, right: 50, bottom: 50, + x: 20, y: 10, toJSON: () => ({}), + }) + return { current: element } +} + +function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) { + const onPick = vi.fn() + const onClose = vi.fn() + const view = render( + , + ) + return { view, onPick, onClose, createWorkspace } +} + +function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void { + const parent = screen.getByRole('menuitem', { name: 'Create workspace' }) + fireEvent.mouseEnter(parent.parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name })) +} + +describe('WorkspacePicker', () => { + it('lists real Workspaces from useWorkspaces and forwards a selected id', () => { + const b = mount() + fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) + expect(b.onPick).toHaveBeenCalledWith(wid('alpha')) + }) + + it('creates a real Workspace from a name and focuses its frontend Session target', async () => { + const created = workspace('new', 'New') + const createWorkspace = vi.fn(async () => created) + const b = mount([], createWorkspace) + chooseCreateItem('Create a new workspace') + const input = screen.getByLabelText('New workspace name') + fireEvent.change(input, { target: { value: 'project-one' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' }) + await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) + }) + + it('adopts an existing path through the same immediate create action', async () => { + const created = workspace('adopted') + const createWorkspace = vi.fn(async () => created) + const b = mount([], createWorkspace) + chooseCreateItem('Use an existing folder') + fireEvent.change(screen.getByLabelText('Existing folder path'), { target: { value: ' /tmp/project ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Use folder' })) + expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) + await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) + }) + + it('blocks a create-new name already present in the Workspace list', () => { + const b = mount([workspace('alpha', 'Alpha')]) + chooseCreateItem('Create a new workspace') + fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } }) + expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.') + expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' }) + expect(b.createWorkspace).not.toHaveBeenCalled() + }) + + it('exposes creation phase and error text while retaining the modal for retry', async () => { + let reject!: (reason: unknown) => void + const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) + const b = mount([], vi.fn(() => pending)) + chooseCreateItem('Create a new workspace') + fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.getByRole('status').textContent).toBe('Creating workspace…') + await act(async () => { reject(new Error('disk unavailable')); await pending.catch(() => {}) }) + expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: disk unavailable') + expect(b.view.getByRole('dialog')).toBeTruthy() + }) + + it('shows list loading through a stable status surface', () => { + const state: WorkspaceListState = { + ...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false, + } + render( + , + ) + expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') + }) +}) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json new file mode 100644 index 0000000000..a2679cccb4 --- /dev/null +++ b/packages/client/ui-workspace/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-sidebar" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-workspace/tsdown.config.ts b/packages/client/ui-workspace/tsdown.config.ts new file mode 100644 index 0000000000..084fe49266 --- /dev/null +++ b/packages/client/ui-workspace/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-workspace', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index f552236890..e15bc4d584 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -164,7 +164,7 @@ class SlotErrorBoundary extends Component< /** * Standard-kit synthesis shared by both scope branches: the global - * useSessions hook, the session pair, the store pair when declared, the + * useSessions/useWorkspaces hooks, the session pair, the store pair when declared, the * renderSlot binding when children are declared, and the SessionProvider * seat when the children declare a session-scope slot. Hosts hand out BARE * observable sources (hooks never cross the host contract); every hook is @@ -174,7 +174,10 @@ class SlotErrorBoundary extends Component< function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCell | undefined): { kit: InjectedProps; actions: object | undefined } { - const kit: InjectedProps = { useSessions: observableHook(host.sessions.list) } + const kit: InjectedProps = { + useSessions: observableHook(host.sessions.list), + useWorkspaces: observableHook(host.workspaces.list), + } if (cell !== undefined) { kit['useSession'] = observableHook(cell.session) kit['sessionId'] = cell.sessionId diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx index 647ef3a11a..3b333d1ba5 100644 --- a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx @@ -38,6 +38,9 @@ function hostOver(core: SlotCore): SlotRendererHost { current: { getSnapshot: () => undefined, subscribe: () => () => {} }, cell: () => undefined, }, + workspaces: { + list: { getSnapshot: () => ({}), subscribe: () => () => {} }, + }, } } diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index aab243ab47..326e01dd4a 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -81,6 +81,7 @@ function makeHost() { const live = new Set() const storeCache = new Map>() const list = observable<{ ids: string[] }>({ ids: [] }) + const workspaces = observable<{ ids: string[] }>({ ids: [] }) const current = observable(undefined) const cells = new Map() @@ -122,10 +123,12 @@ function makeHost() { current, cell: (id) => cells.get(id), }, + workspaces: { list: workspaces }, } return { host, list, + workspaces, current, declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) }, add: (key: string, partial: Omit & { options?: StoredEntry['options'] }) => { @@ -483,6 +486,19 @@ describe('standard-kit synthesis', () => { expect(view.container.textContent).toBe('2') }) + it('delivers a live useWorkspaces hook to every slot component', () => { + const h = makeHost() + h.declare('k.single', SINGLE_ROOT) + h.add('k.single', { + component: ({ useWorkspaces }: { useWorkspaces: (sel: (s: { ids: string[] }) => S) => S }) => + {useWorkspaces((s) => s.ids.length)}, + }) + const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {})) + expect(view.container.textContent).toBe('0') + act(() => { h.workspaces.set({ ids: ['w1'] }) }) + expect(view.container.textContent).toBe('1') + }) + it('delivers the session pair (bound useSession + sessionId) under SessionProvider', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) @@ -710,6 +726,7 @@ describe('inject: execution point, parameter derivation, cache granularity', () (renderSlot) => renderSlot('k.single', { owner: 'owner', shared: 'owner' })) const props = seen.at(-1)! expect(typeof props['useSessions']).toBe('function') // kit always present + expect(typeof props['useWorkspaces']).toBe('function') expect(props['fromInject']).toBe('inject') expect(props['owner']).toBe('owner') expect(props['shared']).toBe('owner') // owner overrides inject diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.spec.tsx index f4654b4ccd..7e73ccef28 100644 --- a/packages/client/web-react/tests/session-provider.spec.tsx +++ b/packages/client/web-react/tests/session-provider.spec.tsx @@ -52,6 +52,7 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea current, cell: (id) => cells.get(id), }, + workspaces: { list: observable({ items: [] }) }, } return { host, diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.spec.tsx index c21555f76a..0f2726e708 100644 --- a/packages/client/web-react/tests/stale-authorization.spec.tsx +++ b/packages/client/web-react/tests/stale-authorization.spec.tsx @@ -42,6 +42,9 @@ function makeHost() { current: { getSnapshot: () => undefined, subscribe: () => () => {} }, cell: () => undefined, }, + workspaces: { + list: { getSnapshot: () => ({}), subscribe: () => () => {} }, + }, } return { host, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 65bfaf7a20..2b76d407f7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -696,6 +696,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'storageDomain', + summary: 'The mounted domain facility.', + methods: [ + { + signature: 'async open(spec: S): Promise>', + jsDoc: '/**\n * Open one declared domain. Steps, each failing the whole call: reject a\n * name that is already open (`already-open`); resolve the backend route\n * (`backend-not-found` passes through from the hub); require its `kv` facet\n * (`facet-unsupported`); open the unit projected from the spec (backend\n * `version-mismatch`/`malformed-medium` pass through); load and validate\n * every stored record against the spec\'s zod schemas (`invalid-record`\n * with the offending table and key); construct the domain.\n *\n * Lifecycle: the CALLER owns the returned handle and closes it via\n * `Domain.close()` (typically as its own `ctx.effect` disposer) — the\n * facility does not tie the domain to any consumer fiber. Domains still\n * open when the facility unmounts are closed by the plugin disposer.\n * @param spec - The domain declaration, typically from `defineDomain`.\n * @returns the opened domain handle, typed by the spec.\n */', + }, + { + signature: 'get(name: string): DomainImpl | undefined', + jsDoc: '/**\n * Look up an open domain by name, untyped. Diagnostic surface (the package\n * invariant cross-checks change events against live domain state); typed\n * consumers hold the handle returned by {@link open}.\n * @param name - Domain name.\n * @returns the open domain runtime, or `undefined` when not open.\n */', + }, + { + signature: 'async closeAll(): Promise', + jsDoc: '/**\n * Close every domain still open on this facility. The unmount path for\n * consumers that never called `Domain.close()` themselves; closing is\n * idempotent, so double-closing an already-closed domain is harmless.\n * @returns resolution after every unit is released.\n */', + }, + ], + }, { key: 'subagents', summary: 'Named provider registry and capability-checked start surface.', @@ -902,23 +920,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'workspace', - summary: 'The workspace registry service.', + summary: 'Durable workspace registry.', methods: [ { signature: 'async create(path: string, title?: string): Promise', - jsDoc: '/**\n * Create a workspace over an existing directory. The path is canonicalized\n * through `fs.realpath` first — a nonexistent path rejects with the\n * original `ENOENT`, a path resolving to anything but a directory rejects,\n * and a canonical path already owned by another workspace (including a\n * symlink resolving to it) rejects.\n * @param path - Directory the workspace points at; canonicalized before storing.\n * @param title - Display title; defaults to `basename` of the canonical path.\n * @returns the created workspace after durability.\n */', + jsDoc: '/**\n * Create or reuse a workspace for an existing directory. The path is\n * canonicalized through `fs.realpath`; a nonexistent path rejects with the\n * original error and a non-directory rejects. Repeated calls for the same\n * canonical path return the existing entity without changing its title.\n * A newly created workspace is prepended to the durable registry order.\n * A different canonical path cannot create a duplicate display title.\n * @param path - Existing directory to own, in any path spelling.\n * @param title - Display title used only when a new record is created.\n * @returns the existing or newly durable workspace.\n */', }, { signature: 'get(id: WorkspaceId): Workspace | undefined', - jsDoc: '/**\n * Look up a workspace by id.\n * @param id - The workspace id.\n * @returns the workspace, or `undefined` when unknown.\n */', + jsDoc: '/**\n * Look up a workspace by id.\n * @param id - Workspace id.\n * @returns the workspace, or `undefined` when unknown.\n */', }, { signature: 'list(): Workspace[]', - jsDoc: '/**\n * Snapshot of all workspaces, in load-then-creation order.\n * @returns a fresh array of the cached entities.\n */', + jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */', + }, + { + signature: 'async touchSession(sessionId: SessionId): Promise', + jsDoc: '/**\n * Move one accounted, cwd-validated session to the front of its workspace.\n * Ungrouped sessions and candidates filtered by the header check are\n * no-ops. The owning workspace\'s relative position never changes.\n * @param sessionId - Session whose activity was observed.\n * @returns resolution after the possible record write.\n */', }, { signature: 'async resolveByPath(path: string): Promise', - jsDoc: '/**\n * Resolve a workspace by directory path, through the same `fs.realpath`\n * canon as {@link create} (hence async). A path that does not exist rejects\n * with the original error — a missing directory has no canonical form to\n * compare (a workspace whose recorded directory vanished is only reachable\n * by id; see `Workspace.status`).\n * @param path - Directory path in any spelling (symlinks, `..`, trailing slash).\n * @returns the owning workspace, or `undefined` when none matches.\n */', + jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', }, ], }, @@ -1490,6 +1512,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DiffResultView', declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}', }, + { + name: 'Domain', + declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}', + }, + { + name: 'DomainGlobal', + declaration: 'export interface DomainGlobal {\n get(): G;\n set(value: G): Promise;\n}', + }, + { + name: 'DomainGlobalHandleOf', + declaration: 'export type DomainGlobalHandleOf = S extends {\n readonly global: DomainGlobalSpec;\n} ? DomainGlobal : never;', + }, + { + name: 'DomainGlobalSpec', + declaration: 'export interface DomainGlobalSpec {\n readonly schema: ZodType;\n readonly initial: G;\n}', + }, + { + name: 'DomainImpl', + declaration: 'export class DomainImpl {\n readonly name: string;\n constructor(private readonly ctx: Context, spec: DomainSpec, private readonly unit: KvUnit, records: Map>, globalValue: unknown, private readonly onClosed: () => void);\n get global(): DomainGlobal;\n table(name: string): KvTable;\n close(): Promise;\n}', + }, + { + name: 'DomainSpec', + declaration: 'export interface DomainSpec {\n readonly name: string;\n readonly version: number;\n readonly global?: DomainGlobalSpec;\n readonly tables: Record;\n}', + }, + { + name: 'DomainTableSpec', + declaration: 'export interface DomainTableSpec {\n readonly valueSchema: ZodType;\n readonly __key?: K;\n}', + }, { name: 'DshEnvironment', declaration: 'export type DshEnvironment = Readonly>;', @@ -1634,6 +1684,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, + { + name: 'KvTable', + declaration: 'export interface KvTable {\n get(key: K): V | undefined;\n entries(): IterableIterator<[\n K,\n V\n ]>;\n keys(): IterableIterator;\n readonly size: number;\n put(key: K, value: V): Promise;\n delete(key: K): Promise;\n update(key: K, fn: (current: V) => V): Promise;\n}', + }, + { + name: 'KvUnit', + declaration: 'export interface KvUnit {\n loadAll(): Promise<{\n tables: Record>;\n global: unknown;\n }>;\n putRecord(table: string, key: string, value: unknown): Promise;\n deleteRecord(table: string, key: string): Promise;\n setGlobal(value: unknown): Promise;\n close(): Promise;\n}', + }, { name: 'LlmAdapter', declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise;\n resolveModelContext(_provider: string, _model: string): Promise;\n abstract stream(options: GenerateOptions): AsyncIterable;\n}', @@ -2134,6 +2192,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SurfaceOp', declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', }, + { + name: 'TableKeyOf', + declaration: 'export type TableKeyOf = S[\'tables\'][N] extends DomainTableSpec ? K : never;', + }, + { + name: 'TableValueOf', + declaration: 'export type TableValueOf = S[\'tables\'][N] extends DomainTableSpec ? V : never;', + }, { name: 'TaskDoneListener', declaration: 'export type TaskDoneListener = (snapshot: TaskSnapshot, owner: Agent | undefined) => void | PromiseLike;', @@ -2432,7 +2498,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Workspace', - declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise;\n attachSession(sessionId: SessionId): Promise;\n detachSession(sessionId: SessionId): Promise;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', + declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise;\n attachSession(sessionId: SessionId): Promise;\n detachSession(sessionId: SessionId): Promise;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', }, ] diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index c07ade2192..cc8b5a7237 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-apiproxy -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The composition lives in `apps/cli/cordis.yml` (the `api-gateway` row). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml). ## Contract layer (`/api`) @@ -10,6 +10,8 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. Session drafts are client-only and have no wire method. + ## Carrier layer (`/client` + root) `AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 471cccc96d..959f84c46b 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", "schemastery": "^3.18.0", "zod": "^4.4.3" }, @@ -57,6 +58,8 @@ "@deepseek-ai/dsh-invariants": "^0.0.1" }, "devDependencies": { + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", "cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "workspace:^" } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e5759d828d..201bc555a9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,16 +5,22 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' +import { join } from 'node:path' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' +import { + workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError, +} from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, + WorkspaceId, WorkspaceView, } from './api/index.ts' import { questionResponsePayloadSchema } from './api/questions.schema.ts' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts' @@ -172,12 +178,14 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade } } -/** Host-level default agent routing: provider/model from the gateway config, cwd from the host process. */ +/** Resolved Host routing and project-directory defaults consumed by the API implementation. */ export interface ApiProxyDefaults { provider: string model: string /** Default project directory for new sessions whose create request carries no cwd. */ cwd: string + /** Parent directory for name-created workspaces. */ + workspaceRoot: string } /** The tool/call payload fields the presenter path reads. */ @@ -273,17 +281,62 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: */ class SessionNotFound extends Error {} +/** Requested identity already belongs to a session with another project cwd. */ +class SessionCwdConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedCwd: string, + readonly existingCwd: string | undefined, + ) { + super( + `session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; ` + + `requested ${JSON.stringify(requestedCwd)}`, + ) + } +} + +/** Host failed before the registry could adopt a name-created directory. */ +class WorkspaceDirectoryCreationError extends Error {} + +/** Wire projection of one workspace entity (the workspace.* value row). */ +function workspaceView(workspace: Workspace): WorkspaceView { + return { + workspaceId: workspace.id, + path: workspace.path, + title: workspace.title, + sessionIds: [...workspace.sessionIds], + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + } +} + +/** Wire projection of the durable record carried by `domain/changed`. */ +function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView { + const record: WorkspaceRecord = workspaceRecord.parse(value) + return { + workspaceId: workspaceId as WorkspaceId, + path: record.path, + title: record.title, + sessionIds: [...record.sessionIds], + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } +} + /** * Implement ApiProxy over a composed host context. - * @param ctx - a context with the host spine mounted (sessions/agents/tools/userInteraction services). - * @param defaults - host-level default provider/model: injected as - * agentOptions on create/resume, reported by describe from the same source. + * @param ctx - a context with the Host spine and Workspace registry mounted. + * @param defaults - host routing and project-directory defaults. * @returns the ApiProxy implementation. */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { const agentOptions = { provider: defaults.provider, model: defaults.model } /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ const resumes = new Map>() + /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ + const sessionCreations = new Map>() + /** Serializes path ownership checks with record creation across spellings. */ + let workspaceCreationChain = Promise.resolve() const pendingQuestions = new Map() const muxQueues = new Set>>() @@ -384,6 +437,78 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } + /** Resolve one requested identity to a live agent, creating or resuming it once. */ + async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise { + let creation = sessionCreations.get(sessionId) + if (creation === undefined) { + creation = (async () => { + const live = ctx.agents.get(sessionId) + if (live !== undefined) return live + + const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined + const stored = persistence === undefined + ? undefined + : (await persistence.list()).find(header => header.id === sessionId) + if (stored !== undefined) { + if (stored.cwd !== cwd) { + throw new SessionCwdConflict(sessionId, cwd, stored.cwd) + } + return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })).agent + } + + try { + await mkdir(cwd, { recursive: true }) + } catch (error: unknown) { + throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) + } + return (await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })).agent + })().catch((error: unknown) => { + // Another Host entry path may have published the same identity while + // this operation crossed an asynchronous persistence/filesystem step. + const live = ctx.agents.get(sessionId) + if (live !== undefined) return live + throw error + }).finally(() => { + sessionCreations.delete(sessionId) + }) + sessionCreations.set(sessionId, creation) + } + const agent = await creation + if (agent.session.header.cwd !== cwd) { + throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd) + } + return agent + } + + /** Resolve or create one path while holding the Host's workspace-create chain. */ + function ensureWorkspace( + path: string, + title: string | undefined, + rejectExistingName = false, + createDirectory = false, + ): Promise<{ workspace: Workspace; created: boolean }> { + const operation = workspaceCreationChain.then(async () => { + if (rejectExistingName && title !== undefined + && ctx.workspace.list().some(workspace => workspace.title === title)) { + throw new WorkspaceNameConflictError(title) + } + if (createDirectory) { + try { + await mkdir(path, { recursive: true }) + } catch (error: unknown) { + throw new WorkspaceDirectoryCreationError( + `failed to create workspace directory "${path}": ${String(error)}`, + ) + } + } + const existing = await ctx.workspace.resolveByPath(path) + if (existing !== undefined) return { workspace: existing, created: false } + return { workspace: await ctx.workspace.create(path, title), created: true } + }) + workspaceCreationChain = operation.then(() => undefined, () => undefined) + return operation + } + return { sessions: { // Attached sessions summarize from memory; persisted-but-unattached (cold) @@ -406,23 +531,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async create(request) { - const sessionId = `session-${randomUUID()}` as SessionId - // A session's cwd is its project path. When the creator does not choose - // one, the default project is the host-level default (the host process - // working directory unless boot overrides it). Ensure the directory - // exists so Create-workspace and typed paths land on a real folder. - const cwd = request.payload.cwd ?? defaults.cwd + const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId + let workspace: Workspace | undefined + if (request.payload.workspaceId !== undefined) { + workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId)) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${request.payload.workspaceId}" not found`, + details: { workspaceId: request.payload.workspaceId }, + }) + } + } + const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd try { - await mkdir(cwd, { recursive: true }) + await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined) } catch (error: unknown) { + if (error instanceof SessionCwdConflict) { + return err(request, { + code: 'session-conflict', + message: error.message, + details: { + sessionId: error.sessionId, + requestedCwd: error.requestedCwd, + ...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }, + }, + }) + } return err(request, { code: 'internal', - message: `failed to ensure project directory "${cwd}": ${String(error)}`, + message: `failed to create session "${sessionId}": ${String(error)}`, details: {}, }) } - const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } }) - return ok(request, { sessionId: handle.agent.id }) + if (workspace !== undefined) { + try { + await workspace.attachSession(sessionId) + } catch (error: unknown) { + return err(request, { + code: 'workspace-attach-failed', + message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, + details: { sessionId, workspaceId: workspace.id }, + }) + } + } + return ok(request, { sessionId }) }, async history(request) { @@ -472,12 +625,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + workspace: { + list(request) { + return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) })) + }, + + // Exactly one of path/name arrives (schema refine). Existing-folder + // adoption reuses its canonical path; create-by-name rejects a name + // already present in the registry. + async create(request) { + const { payload } = request + let path: string + if (payload.name !== undefined) { + const name = payload.name.trim() + if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { + return err(request, { + code: 'workspace-invalid-path', + message: `workspace name must be one non-empty path segment, got "${payload.name}"`, + details: { path: payload.name }, + }) + } + path = join(defaults.workspaceRoot, name) + } else { + path = payload.path as string + } + try { + const name = payload.name?.trim() + const { workspace, created } = await ensureWorkspace( + path, + name, + name !== undefined, + name !== undefined, + ) + return ok(request, { workspace: workspaceView(workspace), created }) + } catch (error: unknown) { + if (error instanceof WorkspaceNameConflictError) { + return err(request, { + code: 'workspace-name-conflict', + message: error.message, + details: { name: error.workspaceName }, + }) + } + if (error instanceof WorkspaceDirectoryCreationError) { + return err(request, { code: 'internal', message: error.message, details: {} }) + } + // The registry rejects a path that does not resolve to an existing + // directory (realpath ENOENT / not-a-directory) — the business + // error of the typed-path flow, surfaced as a validation failure. + return err(request, { + code: 'workspace-invalid-path', + message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`, + details: { path }, + }) + } + }, + + }, + host: { describe(request) { // TODO(step2): version should read apps/cli's package.json; placeholder for now. return Promise.resolve(ok(request, { version: '0.0.1', - cwd: process.cwd(), + // Same source as session.create's fallback: the UI's default project + // must match where an unspecified-cwd session actually lands. + cwd: defaults.cwd, provider: defaults.provider, model: defaults.model, attachedSessions: ctx.agents.list().length, @@ -542,12 +754,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro host(_request, signal) { const queue = new FrameQueue>() + const committedWorkspaceIds = new Set( + ctx.workspace.list().map(workspace => String(workspace.id)), + ) const disposers = [ ctx.on('session/created', (session: Session) => { queue.push(frame({ type: 'host/session-added', sessionId: session.id, ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession }, + // cwd rides the frame so the client list needs no refresh to group the new session. + ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd }, })) }), ctx.on('session/disposed', (session: Session) => { @@ -560,6 +777,29 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => { queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) })) }), + ctx.on('domain/changed', (change) => { + if (change.domain !== 'workspace' || change.operation !== 'put') return + if (change.table === '') { + const state = workspaceDomainState.parse(change.value) + for (const workspaceId of state.workspaceIds) { + if (committedWorkspaceIds.has(workspaceId)) continue + const workspace = ctx.workspace.get(workspaceId) + if (workspace === undefined) { + throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`) + } + committedWorkspaceIds.add(workspaceId) + queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) + } + return + } + if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return + // Existing-entity table writes are complete attach/touch commits. + // A new entity's first put waits for the global registry write above. + queue.push(frame({ + type: 'host/workspace-changed', + workspace: changedWorkspaceView(change.key, change.value), + })) + }), ] return queue.iterate(signal, () => { for (const dispose of disposers) dispose() }) }, diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 0a63305b8a..c2972fc5a3 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -11,6 +11,7 @@ import type { Wire } from './rpc.schema.ts' import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' import { approvalRequestIdSchema } from './approvals.schema.ts' import { sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts' +import { workspaceViewSchema } from './workspace.schema.ts' /** Question shape validated strictly against core dsh-user-interaction. */ export const askUserQuestionItemSchema = z.object({ @@ -39,9 +40,10 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ /** HostFrame union (payload slot of a host-stream ServerRequest). */ export const hostFrameSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional() }), + z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }), z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }), z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }), z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), + z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index c03877c31d..17d2952cb2 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -12,6 +12,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' import type { RpcError, RpcId, RpcRequest } from './rpc.ts' +import type { WorkspaceView } from './workspace.ts' // Client-side consumers take the render-intent vocabulary from the contract; // dsh-tools remains its owner. @@ -62,10 +63,18 @@ export type MuxFrame = | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' } | { type: 'stream/error'; error: RpcError } -/** Host stream frames. session-added carries the lineage anchor; agent-error is the only outlet for live failures with no turn position. */ +/** + * Host stream frames. session-added carries the lineage anchor and the + * project cwd (the list-summary fields a client cannot wait for a refresh to + * learn); agent-error is the only outlet for live failures with no turn + * position; workspace-changed pushes the full new snapshot after every + * durable workspace mutation (create/attach/order change — the client + * upserts, while `workspace.list` provides the reconnect baseline). + */ export type HostFrame = - | { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId } + | { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId; cwd?: string } | { type: 'host/session-removed'; sessionId: SessionId } | { type: 'host/session-status'; sessionId: SessionId; running: boolean } | { type: 'host/agent-error'; sessionId: SessionId; message: string } + | { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'stream/error'; error: RpcError } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c2fbb0d189..ce8e863658 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -6,6 +6,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' +import type { WorkspaceApi } from './workspace.ts' import type { EventsApi } from './events.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' @@ -13,6 +14,7 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts' export interface ApiProxy { sessions: SessionsApi host: HostApi + workspace: WorkspaceApi events: EventsApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise @@ -21,6 +23,7 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.ts' +export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b37cc062ff..1f0f9acef4 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -6,6 +6,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' +import type { WorkspaceApi } from './workspace.ts' import type { RpcResponse } from './rpc.ts' /** Method name → method signature. Signatures are the single source of truth; payload/value types are always derived from here. */ @@ -16,6 +17,8 @@ export interface RpcMethodMap { 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] + 'workspace.list': WorkspaceApi['list'] + 'workspace.create': WorkspaceApi['create'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 20ef251cd6..3b290e18c3 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -35,6 +35,11 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom()) }) }), z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), + z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }), + z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }), + z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }), + z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), + z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 53b2fc43e8..dbcc975d04 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -32,6 +32,11 @@ export interface RpcErrorDetailsMap { 'bad-request': { issues: ZodIssue[] } 'cancelled': {} 'session-not-found': { sessionId: SessionId } + 'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string } + 'workspace-attach-failed': { sessionId: SessionId; workspaceId: string } + 'workspace-not-found': { workspaceId: string } + 'workspace-invalid-path': { path: string } + 'workspace-name-conflict': { name: string } 'agent-busy': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 3edf0e6014..441d02e4df 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -11,10 +11,19 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { HistoryEntry, SessionSummary } from './sessions.ts' import type { ToolEventView } from './events.ts' +import type { WorkspaceId } from './workspace.ts' /** SessionId: one brand cast after shape validation (the only cast point in this domain). */ export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType +/** + * WorkspaceId: the workspace domain's one brand cast. Hosted here rather + * than in workspace.schema because session.create references it while + * workspace.schema references sessionIdSchema — schema modules must stay a + * DAG (both casts used at module top level; a cycle is a load-time TDZ). + */ +export const workspaceIdSchema = z.string().min(1) as unknown as z.ZodType + /** SessionEvent passthrough: strict envelope, wide data (the client fold handles unknown types via its documented default). */ export const sessionEventSchema = z.object({ type: z.string(), @@ -44,10 +53,15 @@ export const sessionListValueSchema = z.object({ items: z.array(sessionSummarySchema), }) satisfies z.ZodType>> -/** session.create request payload. */ +/** session.create request payload (at most one of workspaceId / cwd). */ export const sessionCreateRequestSchema = z.object({ + workspaceId: workspaceIdSchema.optional(), cwd: z.string().optional(), -}) satisfies z.ZodType>> + sessionId: sessionIdSchema.optional(), +}).refine( + payload => payload.workspaceId === undefined || payload.cwd === undefined, + { message: 'session.create accepts workspaceId or cwd, not both' }, +) satisfies z.ZodType>> /** session.create response value. */ export const sessionCreateValueSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 393303f817..5f3e3d8740 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -8,6 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts' import type { ToolEventView } from './events.ts' +import type { WorkspaceId } from './workspace.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -49,8 +50,16 @@ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ list(request: RpcRequest<{ cursor?: string }>): Promise> - /** Creates a new session (and its agent, idle and standing by). */ - create(request: RpcRequest<{ cwd?: string }>): Promise> + /** + * Creates a real session and its idle agent. At most one of `workspaceId` / + * `cwd` is accepted; an omitted project uses the Host cwd. A caller may + * preallocate `sessionId`: retries with the same id and cwd return the same + * session, while a different cwd fails with `session-conflict`. + * Workspace creation attaches the session after publication; an attach + * failure returns `workspace-attach-failed` with the published session id. + */ + create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>): + Promise> /** * Reads a window of history events; page boundaries align to message boundaries: one page = diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts new file mode 100644 index 0000000000..a193fb0c57 --- /dev/null +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -0,0 +1,46 @@ +/** + * workspace domain zod schemas (names derived from map keys). The + * WorkspaceId brand cast lives in sessions.schema (see the note there) and + * is re-exported here as the domain-local name. + */ + +import { z } from 'zod' +import type { RequestPayload, ResponseValue } from './rpc-map.ts' +import type { Wire } from './rpc.schema.ts' +import type { WorkspaceView } from './workspace.ts' +import { sessionIdSchema, workspaceIdSchema } from './sessions.schema.ts' + +export { workspaceIdSchema } from './sessions.schema.ts' + +/** WorkspaceView row of every workspace.* response. */ +export const workspaceViewSchema = z.object({ + workspaceId: workspaceIdSchema, + path: z.string(), + title: z.string(), + sessionIds: z.array(sessionIdSchema), + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType> + +/** workspace.list request payload (empty object literal). */ +export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType>> + +/** workspace.list response value. */ +export const workspaceListValueSchema = z.object({ + items: z.array(workspaceViewSchema), +}) satisfies z.ZodType>> + +/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ +export const workspaceCreateRequestSchema = z.object({ + path: z.string().optional(), + name: z.string().optional(), +}).refine( + payload => (payload.path === undefined) !== (payload.name === undefined), + { message: 'workspace.create requires exactly one of path / name' }, +) satisfies z.ZodType>> + +/** workspace.create response value. */ +export const workspaceCreateValueSchema = z.object({ + workspace: workspaceViewSchema, + created: z.boolean(), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts new file mode 100644 index 0000000000..86c20e2ff5 --- /dev/null +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -0,0 +1,55 @@ +/** + * workspace domain contract. Wire projection of the host-side workspace + * entity (@deepseek-ai/dsh-workspace): a stable id over a directory path, + * a display title, and the ordered session account. Method signatures are the + * source of truth, same as the sessions domain. + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { RpcRequest, RpcResponse } from './rpc.ts' + +/** + * Wire-side workspace id brand. Deliberately re-declared here rather than + * imported from dsh-workspace: api/ must stay browser-importable with zero + * host-package dependencies, and the brand string matches, so both sides + * agree structurally. + */ +export type WorkspaceId = Branded<'WorkspaceId'> + +/** One workspace row: the record projection every workspace.* value carries. */ +export interface WorkspaceView { + workspaceId: WorkspaceId + /** Canonical directory path (host-side realpath canon). */ + path: string + /** Unique display title (defaults to the path basename at create). */ + title: string + /** Sessions accounted under this workspace, newest-first for display. */ + sessionIds: SessionId[] + /** ISO-8601 creation instant. */ + createdAt: string + /** ISO-8601 last-mutation instant. */ + updatedAt: string +} + +/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */ +export interface WorkspaceApi { + /** Lists all workspaces in the registry's durable display order. */ + list(request: RpcRequest<{}>): Promise> + + /** + * Creates (or idempotently resolves) a workspace. Exactly one of `path` / + * `name` (schema-enforced): `path` registers an EXISTING directory (no + * mkdir — a missing or non-directory path fails with `workspace-invalid-path`); + * `name` is a single path segment the host mkdirs under its default project + * root before registering. Either spelling resolving to a directory already + * owned by a workspace returns that workspace (`created: false`) for the + * existing-folder spelling. Create-by-name rejects an existing title with + * `workspace-name-conflict`; a new path whose basename duplicates another + * Workspace title is rejected by the registry with the same code. + * A new name-created workspace uses `name` as both directory name and title; + * a path-created workspace uses the registry's basename title default. + */ + create(request: RpcRequest<{ path?: string; name?: string }>): + Promise> +} diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 901cf7bd2a..81749fc219 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -21,6 +21,10 @@ import { sessionListValueSchema, sessionPromptValueSchema, } from '../api/sessions.schema.ts' +import { + workspaceCreateValueSchema, + workspaceListValueSchema, +} from '../api/workspace.schema.ts' /** * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary @@ -48,6 +52,10 @@ export interface IApiClient { host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> } + workspace: { + list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> + create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> + } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -67,6 +75,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('host.describe', payload, signal), } + readonly workspace: IApiClient['workspace'] = { + list: (payload, signal) => this.callUnary('workspace.list', payload, signal), + create: (payload, signal) => this.callUnary('workspace.create', payload, signal), + } + readonly events: IApiClient['events'] = { mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen), host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 03b9f6500f..e876d664b2 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -22,6 +22,10 @@ import { sessionPromptRequestSchema, } from '../api/sessions.schema.ts' import { hostDescribeRequestSchema } from '../api/host.schema.ts' +import { + workspaceCreateRequestSchema, + workspaceListRequestSchema, +} from '../api/workspace.schema.ts' /** * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a @@ -44,6 +48,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, + 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, + 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index aba792bd9c..06e2f01748 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -8,6 +8,7 @@ * routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. */ +import { resolve } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' import type { ApiProxy } from './api/index.ts' @@ -28,37 +29,47 @@ declare module 'cordis' { } } -/** Gateway plugin config: the host-level default agent routing. */ +/** Gateway plugin config: host-level agent routing and Workspace creation root. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string + /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ + workspaceRoot?: string } /** * The API gateway service: implements the ApiProxy contract over the composed - * host context and provides it as `ctx.apiProxy`. The default project - * directory for new sessions is the host process working directory (not a - * config field this round). + * host context and provides it as `ctx.apiProxy`. The Host cwd is the default + * project directory and the fallback parent for name-created Workspaces. */ export class ApiProxyService extends Service implements ApiProxy { - static inject = ['agents', 'sessions', 'tools', 'userInteraction'] + static inject = ['agents', 'sessions', 'tools', 'userInteraction', 'workspace'] static Config: z = z.object({ provider: z.string().required(), model: z.string().required(), + workspaceRoot: z.string(), }) readonly sessions: ApiProxy['sessions'] + readonly workspace: ApiProxy['workspace'] readonly host: ApiProxy['host'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') - const api = createApiProxy(ctx, { provider: config.provider, model: config.model, cwd: process.cwd() }) + const cwd = process.cwd() + const api = createApiProxy(ctx, { + provider: config.provider, + model: config.model, + cwd, + workspaceRoot: resolve(config.workspaceRoot ?? cwd), + }) this.sessions = api.sessions + this.workspace = api.workspace this.host = api.host this.events = api.events // createApiProxy returns closures (no `this` capture); bind only satisfies diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index a3e2bf4e7a..4790e4254d 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -55,7 +55,7 @@ describe('sessions.list cold merge', () => { return undefined }, }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const response = await api.sessions.list(request({})) expect(response.result.ok).toBe(true) @@ -79,7 +79,7 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const listed = await api.sessions.list(request({})) expect(listed.result.ok).toBe(true) diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 596bf25ac8..86ffa56eb4 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -76,7 +76,7 @@ async function collect(iterable: AsyncIterable>, count: num describe('mux live view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const collected = collect(stream, 9, abort) @@ -122,7 +122,7 @@ describe('mux live view computation', () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -156,7 +156,7 @@ describe('mux live view computation', () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) @@ -177,7 +177,7 @@ describe('mux live view computation', () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const collected = collect(stream, 4, abort) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts new file mode 100644 index 0000000000..a3dd98c5a9 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -0,0 +1,246 @@ +import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import Storage from '@deepseek-ai/dsh-storage' +import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' +import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' + +let nextRpc = 1 + +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload } +} + +function expectOk(response: RpcResponse): T { + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + return response.result.value +} + +async function nextHostFrame( + stream: AsyncIterator>, +): Promise> { + const next = await stream.next() + if (next.done === true) throw new Error('Host stream ended before the expected increment') + return next.value +} + +function stubAgent(session: Session): Agent { + return { + id: session.id, + options: {}, + session, + status: 'idle', + ctx: new Context(), + followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), + cancel() {}, + whenIdle: () => Promise.resolve(), + } +} + +/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ +async function harness( + workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), +) { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend()) + const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', storageDomain) + ctx.provide('storageDomain', storageDomain) + ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) + await ctx.plugin(WorkspaceRegistry) + + const factory: AgentFactory = { + async createAgent(_ownerCtx, options) { + const session = ctx.sessions.create( + options.sessionId, + options.meta === undefined ? {} : { meta: options.meta }, + ) + const agent = stubAgent(session) + const unregister = ctx.agents.register(agent) + return { + agent, + dispose: () => { + unregister() + return Promise.resolve() + }, + } + }, + async resume() { + throw new Error('test harness has no persisted sessions') + }, + } + ctx.agents.setFactory(factory) + const api = createApiProxy(ctx, { + provider: 'test', + model: 'test-model', + cwd: workspaceRoot, + workspaceRoot, + }) + return { api, ctx, storageDomain, workspaceRoot } +} + +describe('workspace.create', () => { + it('serializes concurrent names and rejects the duplicate', async () => { + const { api, workspaceRoot } = await harness() + const responses = await Promise.all([ + api.workspace.create(request({ name: 'alpha' })), + api.workspace.create(request({ name: 'alpha' })), + ]) + const created = responses.find(response => response.result.ok) + const duplicate = responses.find(response => !response.result.ok) + + expect(created).toBeDefined() + expect(expectOk(created!)).toMatchObject({ + created: true, + workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' }, + }) + expect(duplicate?.result).toMatchObject({ + ok: false, + error: { code: 'workspace-name-conflict', details: { name: 'alpha' } }, + }) + expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true) + }) + + it('adopts only existing directories and rejects unsafe names', async () => { + const { api, workspaceRoot } = await harness() + const existing = join(workspaceRoot, 'existing') + mkdirSync(existing) + const first = expectOk(await api.workspace.create(request({ path: existing }))) + const repeated = expectOk(await api.workspace.create(request({ path: existing }))) + expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) + expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } }) + + const missing = join(workspaceRoot, 'missing') + const missingResult = await api.workspace.create(request({ path: missing })) + expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) + expect(existsSync(missing)).toBe(false) + + for (const name of ['', '.', '..', 'a/b', 'a\\b']) { + const invalid = await api.workspace.create(request({ name })) + expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) + } + }) +}) + +describe('session creation and Workspace membership', () => { + it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { + const { api, ctx } = await harness() + const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const sessionId = SessionId('session-workspace-preallocated') + + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) + expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1) + + const ungrouped = SessionId('session-cwd-only') + expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped }))) + expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped) + + const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId })) + expect(conflict.result).toMatchObject({ + ok: false, + error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } }, + }) + const missing = await api.sessions.create(request({ + workspaceId: 'missing-workspace' as WorkspaceId, + sessionId: SessionId('session-missing-workspace'), + })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + }) + + it('retains a published session when attachment fails and repairs it on retry', async () => { + const { api, ctx } = await harness() + const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const workspace = ctx.workspace.list()[0] + if (workspace === undefined) throw new Error('workspace missing from registry') + vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure')) + const sessionId = SessionId('session-attach-retry') + + const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })) + expect(failed.result).toMatchObject({ + ok: false, + error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } }, + }) + expect(ctx.agents.get(sessionId)).toBeDefined() + + expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))) + expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) + }) +}) + +describe('Host Workspace increments', () => { + it('streams committed Workspace and Session increments after empty baselines', async () => { + const { api } = await harness() + expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) + expect(expectOk(await api.sessions.list(request({}))).items).toEqual([]) + + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const workspaceIncrement = nextHostFrame(stream) + const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + expect(await workspaceIncrement).toMatchObject({ + payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } }, + }) + + const sessionId = SessionId('session-streamed-workspace') + const pending = nextHostFrame(stream) + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + const increments: HostFrame[] = [] + increments.push((await pending).payload) + while (increments.length < 2) { + const next = await stream.next() + if (next.done === true) throw new Error('Host stream ended before both increments') + increments.push(next.value.payload) + } + expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({ + type: 'host/session-added', sessionId, cwd: workspace.path, + }) + const workspaceChanged = increments.find( + (increment): increment is Extract => + increment.type === 'host/workspace-changed', + ) + expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId]) + abort.abort() + }) + + it('does not publish a Workspace whose registry-order commit fails', async () => { + const { api, storageDomain } = await harness() + const domain = storageDomain.get('workspace') + if (domain === undefined) throw new Error('workspace domain is not open') + vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure')) + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const next = stream.next() + + const failed = await api.workspace.create(request({ name: 'ghost' })) + expect(failed.result.ok).toBe(false) + expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) + abort.abort() + expect(await next).toMatchObject({ done: true }) + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25af7e2f75..7dec5980eb 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -34,6 +34,10 @@ function scriptedApi(overrides: { ...overrides.sessions, }, host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host }, + workspace: { + list: r => ok(r, { items: [] }), + create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), + }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), } @@ -190,6 +194,23 @@ describe('unary round trip', () => { }) }) +describe('workspace domain round trip', () => { + it('routes both workspace methods through their handler rows and value schemas', async () => { + const c = client(scriptedApi()) + const list = await c.workspace.list({}) + expect(list.result).toEqual({ ok: true, value: { items: [] } }) + const created = await c.workspace.create({ path: '/t' }) + expect(created.result.ok).toBe(true) + if (created.result.ok) expect(created.result.value.created).toBe(true) + }) + + it('rejects a create payload violating the exactly-one refine at the handler', async () => { + const response = await client(scriptedApi()).workspace.create({}) + expect(response.result.ok).toBe(false) + if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') + }) +}) + describe('SSE stream path', () => { it('yields frames in order and skips the comment preamble', async () => { const frames: MuxFrame[] = [ diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d097daecef..ede6acb9ac 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -42,6 +42,17 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } }, }, + workspace: { + async list(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } + }, + async create(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } }, + } + }, + }, events: { mux: (_request, signal) => stream(muxFrames, signal), host: (_request, signal) => stream(hostFrames, signal), diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 0c3eb2b320..d0f2ddd128 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -12,6 +12,10 @@ import { sessionPromptValueSchema, sessionSummarySchema, } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' +import { + workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema, + workspaceListValueSchema, workspaceViewSchema, +} from '../src/api/workspace.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' @@ -31,6 +35,11 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled') expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') + expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict') + expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed') + expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found') + expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') + expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -96,6 +105,9 @@ describe('sessions domain schemas', () => { expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c') expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([]) expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w') + // The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects. + expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1') + expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/) expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3) expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow() @@ -119,6 +131,31 @@ describe('host domain schemas', () => { }) }) +describe('workspace domain schemas', () => { + const view = { + workspaceId: 'w1', path: '/p', title: 'p', sessionIds: ['s1'], + createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z', + } + + it('validates ids, the view row, and list request/value', () => { + expect(workspaceIdSchema.parse('w1')).toBe('w1') + expect(() => workspaceIdSchema.parse('')).toThrow() + expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1']) + expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow() + expect(workspaceListRequestSchema.parse({})).toEqual({}) + expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1) + }) + + it('create requires exactly one of path/name (both refine arms)', () => { + expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') + expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n') + expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/) + expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/) + expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) + }) + +}) + describe('events frame schemas', () => { it('accepts every mux frame branch', () => { const frames = [ diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 4e22627590..9b0ae88d04 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -41,6 +41,9 @@ { "path": "../../ui/user-interaction" }, + { + "path": "../../workspace/workspace" + }, { "path": "../../support/invariants" } diff --git a/packages/storage/README.md b/packages/storage/README.md index f112cdb5a0..fc541e7ea5 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -7,6 +7,6 @@ The storage family persists everything that is not a session event log: a hub wh | `storage/` | The hub: named backend registry + merge-extensible data-form mounts, backend facet vocabulary, shared conformance suite | `ctx.storage` | | `storage-json/` | JSON backend: one human-readable file per unit, atomic whole-file rewrite | registers backend `json` | | `storage-sqlite/` | SQLite backend: one database hosting all routed units, document-per-row | registers backend `sqlite` | -| `domain/` | Domain data form: zod-validated records, per-domain write chain, `domain/changed` events, backend routing by configuration | mounts `ctx.storage.domain` | +| `domain/` | Domain data form: zod-validated records, per-domain write chain, `domain/changed` events, backend routing by configuration | `ctx.storageDomain` + `ctx.storage.domain` | -Backends own one medium each and expose data-shape **facets** (`kv` today; an append-log facet is reserved for the future session-backend migration). Consumers never touch backends directly — they open declared domains through the domain form. +Backends own one medium each and expose data-shape **facets** (`kv` today; an append-log facet is reserved for the future session-backend migration). Each backend plugin publishes an internal lifecycle service after registration; the domain plugin injects every configured backend key before exposing its own service, so config-tree row order carries no startup semantics. Consumers never touch backends directly — they inject `storageDomain` and open declared domains through it. diff --git a/packages/storage/storage-domain/README.md b/packages/storage/storage-domain/README.md index e89c2f1d5b..3a707f0b16 100644 --- a/packages/storage/storage-domain/README.md +++ b/packages/storage/storage-domain/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-storage-domain -Domain data form for the DeepSeek Harness storage hub: mounts `ctx.storage.domain`, opening schema-validated KV domains over configured storage backends. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`. The opening consumer owns the handle's lifecycle and releases it with `Domain.close()` (idempotent; typically its own `ctx.effect` disposer); domains still open when the plugin unmounts are closed by the facility. +Domain data form for the DeepSeek Harness storage hub: exposes the injectable `ctx.storageDomain` service and the matching `ctx.storage.domain` projection after every configured backend is registered. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`. The opening consumer owns the handle's lifecycle and releases it with `Domain.close()` (idempotent; typically its own `ctx.effect` disposer); domains still open when the plugin unmounts are closed by the facility. Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). @@ -17,7 +17,7 @@ Design rationale, open semantics, and the storage/domain layer split live in the #### What the model sees -Nothing. The package registers no tools, injects no prompts, and appends no session events; it stores non-session data (workspace records, future session sidecars) behind `ctx.storage.domain` and emits only the in-process `domain/changed` event, which reaches a model only if a consumer package renders it through its own documented surface. +Nothing. The package registers no tools, injects no prompts, and appends no session events; it stores non-session data (workspace records, future session sidecars) behind `ctx.storageDomain` and emits only the in-process `domain/changed` event, which reaches a model only if a consumer package renders it through its own documented surface. #### Token effect diff --git a/packages/storage/storage-domain/src/index.ts b/packages/storage/storage-domain/src/index.ts index 974fcadc16..cb6c5f0f77 100644 --- a/packages/storage/storage-domain/src/index.ts +++ b/packages/storage/storage-domain/src/index.ts @@ -9,6 +9,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import { DomainError } from './error.ts' import { descriptorOf } from './spec.ts' import type { DomainSpec } from './spec.ts' @@ -31,6 +32,12 @@ declare module '@deepseek-ai/dsh-storage' { } } +declare module 'cordis' { + interface Context { + storageDomain: DomainFacility + } +} + /** Cordis plugin name. */ export const name = 'storage-domain' /** The storage hub must be present before the form can mount. */ @@ -188,16 +195,26 @@ function parseRecord(domain: string, table: string, key: string, parse: () => * Mount the domain data form on the storage hub. * @param ctx - Plugin context. * @param config - Validated plugin config. + * @returns resolution after an already-available backend set activates the form. */ -export function apply(ctx: Context, config: Config) { - const facility = new DomainFacility(ctx, config) - ctx.effect(() => { - const unmount = ctx.storage.mount('domain', facility) - return async () => { - // Close leftovers before unmounting: draining writes still emit - // domain/changed, whose invariant resolves the facility through the hub. - await facility.closeAll() - unmount() - } +export function apply(ctx: Context, config: Config): Promise { + const backendServices = [...new Set([ + config.backend, + ...Object.values(config.routes ?? {}), + ])].map(storageBackendServiceKey) + + const fiber = ctx.inject(backendServices, (domainCtx) => { + const facility = new DomainFacility(domainCtx, config) + domainCtx.effect(() => { + const unmount = domainCtx.storage.mount('domain', facility) + return async () => { + // Close leftovers before unmounting: draining writes still emit + // domain/changed, whose invariant resolves the facility through the hub. + await facility.closeAll() + unmount() + } + }) + domainCtx.provide('storageDomain', facility) }) + return Promise.resolve(fiber).then(() => {}) } diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 8d02cd50e5..761c1d7c1d 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { z } from 'zod' -import Storage from '@deepseek-ai/dsh-storage' +import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import { DomainFacility, defineDomain, domainTable } from '../src/index.ts' import type { Config } from '../src/index.ts' import type { DomainChanged } from '../src/events.ts' @@ -151,15 +151,26 @@ describe('DomainFacility.open', () => { }) describe('plugin apply', () => { - it('mounts the facility as ctx.storage.domain through the plugin effect', async () => { + it('waits for routed backends, then mounts one lifecycle-bound service and form', async () => { const ctx = new Context() await ctx.plugin(Storage) - ctx.storage.backend.register('memory', new MemoryStorageBackend()) const DomainPlugin = await import('../src/index.ts') const fiber = await ctx.plugin(DomainPlugin, { backend: 'memory' }) - expect(ctx.storage.domain).toBeInstanceOf(DomainFacility) - await fiber.dispose() + expect(ctx.get('storageDomain')).toBeUndefined() expect(() => ctx.storage.form('domain')).toThrow(/not mounted/) + + const backend = new MemoryStorageBackend() + ctx.storage.backend.register('memory', backend) + const disposeBackend = ctx.provide(storageBackendServiceKey('memory'), backend) + await vi.waitFor(() => { expect(ctx.storageDomain).toBeInstanceOf(DomainFacility) }) + expect(ctx.storage.domain).toBe(ctx.storageDomain) + + disposeBackend() + await vi.waitFor(() => { + expect(ctx.get('storageDomain')).toBeUndefined() + expect(() => ctx.storage.form('domain')).toThrow(/not mounted/) + }) + await fiber.dispose() }) }) @@ -295,10 +306,12 @@ describe('close and lifecycle', () => { it('facility unmount closes domains the consumer never closed', async () => { const ctx = new Context() await ctx.plugin(Storage) - ctx.storage.backend.register('memory', new MemoryStorageBackend()) + const backend = new MemoryStorageBackend() + ctx.storage.backend.register('memory', backend) + ctx.provide(storageBackendServiceKey('memory'), backend) const DomainPlugin = await import('../src/index.ts') const fiber = await ctx.plugin(DomainPlugin, { backend: 'memory' }) - const domain = await ctx.storage.domain.open(bareSpec) + const domain = await ctx.storageDomain.open(bareSpec) const table = domain.table('rows') await table.put('a', { label: 'x', count: 1 }) await fiber.dispose() diff --git a/packages/storage/storage-json/src/index.ts b/packages/storage/storage-json/src/index.ts index b80185ecf7..c2c0ac0dd8 100644 --- a/packages/storage/storage-json/src/index.ts +++ b/packages/storage/storage-json/src/index.ts @@ -9,7 +9,7 @@ import { mkdir } from 'node:fs/promises' import { join } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' -import { StorageError, UNIT_NAME_RE } from '@deepseek-ai/dsh-storage' +import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage' import { openJsonUnit } from './unit.ts' @@ -110,4 +110,5 @@ export function apply(ctx: Context, config: Config) { await backend.close() } }) + ctx.provide(storageBackendServiceKey('json'), backend) } diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index 870498f0ea..2f2fff90fc 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import Storage from '@deepseek-ai/dsh-storage' +import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import InvariantService from '@deepseek-ai/dsh-invariants' import { runKvBackendContract } from '../../storage/tests/contract.ts' import { Config, JsonStorageBackend, apply } from '../src/index.ts' @@ -185,10 +185,12 @@ describe('json backend specifics', () => { await ctx.plugin(Storage) const fiber = await ctx.plugin({ apply, Config, inject: ['storage'] }, { root }) const backend = ctx.storage.backend.get('json') + expect(ctx.get(storageBackendServiceKey('json'))).toBe(backend) const unit = await backend.kv!.open(descriptor) await unit.putRecord('t', 'k', { v: 1 }) await fiber.dispose() expect(() => ctx.storage.backend.get('json')).toThrow() + expect(ctx.get(storageBackendServiceKey('json'))).toBeUndefined() await expect(unit.putRecord('t', 'x', {})).rejects.toMatchObject({ code: 'closed' }) }) diff --git a/packages/storage/storage-sqlite/src/index.ts b/packages/storage/storage-sqlite/src/index.ts index 72fff382bc..eff5bb80fb 100644 --- a/packages/storage/storage-sqlite/src/index.ts +++ b/packages/storage/storage-sqlite/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { DatabaseSync } from 'node:sqlite' -import { StorageError, UNIT_NAME_RE } from '@deepseek-ai/dsh-storage' +import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage' import { openDatabase, recordTableName, type JournalMode } from './schema.ts' import { SqliteKvUnit } from './unit.ts' @@ -164,4 +164,5 @@ export function apply(ctx: Context, config: Config) { await backend.close() } }, 'storage-sqlite.registerBackend') + ctx.provide(storageBackendServiceKey('sqlite'), backend) } diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index b5ddd46fb1..8e64fb40f2 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -4,7 +4,7 @@ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' -import Storage from '@deepseek-ai/dsh-storage' +import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage' import { runKvBackendContract } from '../../storage/tests/contract.ts' import * as StorageSqlite from '../src/index.ts' @@ -233,11 +233,13 @@ describe('sqlite backend specifics', () => { await ctx.plugin(Storage) const fiber = await ctx.plugin(StorageSqlite, { path: ':memory:' }) const backend = ctx.storage.backend.get('sqlite') + expect(ctx.get(storageBackendServiceKey('sqlite'))).toBe(backend) const unit = await backend.kv!.open(DESCRIPTOR) await unit.putRecord('records', 'k', { n: 1 }) await fiber.dispose() expect(ctx.storage.backend.names()).toEqual([]) + expect(ctx.get(storageBackendServiceKey('sqlite'))).toBeUndefined() await expect(backend.kv!.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' }) }) diff --git a/packages/storage/storage/src/index.ts b/packages/storage/storage/src/index.ts index 15fb70d778..4a24d88cc5 100644 --- a/packages/storage/storage/src/index.ts +++ b/packages/storage/storage/src/index.ts @@ -15,6 +15,18 @@ export type { StorageErrorCode } from './error.ts' export { UNIT_NAME_RE } from './backend.ts' export type { StorageBackend, KvFacet, KvUnit, KvUnitDescriptor } from './backend.ts' +/** + * Derive the Cordis lifecycle service that one named backend plugin provides. + * Domain-form providers inject these keys so activation cannot race backend + * registration even though callers continue resolving backends through the + * storage registry. + * @param name - Backend registry name. + * @returns the corresponding lifecycle-only service key. + */ +export function storageBackendServiceKey(name: string): string { + return `storage.backend.${name}` +} + declare module 'cordis' { interface Context { storage: Storage diff --git a/packages/storage/storage/tests/registry.spec.ts b/packages/storage/storage/tests/registry.spec.ts index 413bbe914c..232efc3640 100644 --- a/packages/storage/storage/tests/registry.spec.ts +++ b/packages/storage/storage/tests/registry.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import Storage, { BackendRegistry } from '../src/index.ts' +import Storage, { BackendRegistry, storageBackendServiceKey } from '../src/index.ts' import type { StorageBackend } from '../src/index.ts' const fakeBackend = (): StorageBackend => ({ close: async () => {} }) @@ -25,6 +25,11 @@ describe('BackendRegistry', () => { }) describe('Storage service', () => { + it('derives stable lifecycle service keys for named backends', () => { + expect(storageBackendServiceKey('json')).toBe('storage.backend.json') + expect(storageBackendServiceKey('tenant-a')).toBe('storage.backend.tenant-a') + }) + it('mounts on the context and exposes registry plus form mounting', async () => { const ctx = new Context() await ctx.plugin(Storage) diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index a32528517f..a687910319 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -1,18 +1,19 @@ # @deepseek-ai/dsh-workspace -Workspace entity registry (`ctx.workspace`) for the DeepSeek Harness: durable workspace records — a stable `WorkspaceId`, a canonical directory path, a display title, and the ordered account of owned sessions — stored through the domain data form (`workspaceDomainSpec`, table `workspaces`). Consumers see the `Workspace` interface only; the entity implementation stays package-private. +Workspace entity registry (`ctx.workspace`) for the DeepSeek Harness: durable workspace records, stable workspace order, and a newest-first candidate session index stored through the domain data form. Consumers see the `Workspace` interface; the entity implementation stays package-private. -Design rationale, the path/uniqueness canon, and the consistency rules live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). +The entity/storage rationale lives in the [domain Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md); header-only bootstrap and GUI ordering live in the [Workspace GUI Agent Note](../../../.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md). ## Shape -- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath` (trailing slashes, `..`, symlinks), rejects a nonexistent path (the original `ENOENT`), a path resolving to anything but a directory, and a canonical path another workspace already owns. Title defaults to `basename(path)`. -- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups; `resolveByPath` is async because it runs the same `realpath` canon first. -- `Workspace.attachSession(id)` — idempotent; validates that the session's stored header `cwd`, canonicalized the same way, equals the workspace path. A missing persistence service, unknown session, absent or unresolvable `cwd`, or mismatch rejects without writing (what cannot be validated is not recorded). `detachSession` removes from the account only, never touching the session's own log. -- `Workspace.sessionIds` — the ordered ownership account (array order is display order). Accounted ids whose session no longer exists are filtered from the projection and pruned durably on the next mutation. A medium accounting one session under two workspaces, or claiming one canonical path from two records, rejects at startup (external edit — the write side makes both unreachable). Attach/detach idempotence is decided on the domain write chain, so unawaited concurrent calls settle in call order. +- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title. +- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. +- `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. +- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes. +- `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. - `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record. -Session persistence is an optional peer resolved with `ctx.get`: absent, attach rejects and projections serve the account unfiltered. +`storageDomain` and `sessionPersistence` are required startup dependencies. An unavailable peer leaves the plugin pending and cannot commit an empty initialized marker. On the first successful start, the registry calls `SessionPersistence.list()` and uses only header `id`, `cwd`, and `createdAt` to group valid historical directories and persist initial order; it never reads event bodies. The initialized marker is written last, so partial bootstrap writes are reused safely after restart. Later cwd-only sessions remain Ungrouped. ## Model Experience @@ -33,5 +34,4 @@ Independent of live requests: the package never touches a request prefix, so it ## Known Limitations and Deferred Work - No delete entry point in this phase — workspace deletion ships as one complete semantic together with the session-delete primitive and cascade orchestration (future-work section of the Agent Note); a half "drop the record, keep the sessions" operation is deliberately not exposed. -- No RPC surface or GUI wiring yet; the record schema is the direct source of the next phase's wire projection. -- The known-session view refreshes at startup and on attach validation; a session deleted by an external process during this one is filtered only after the next refresh. +- The header index refreshes at startup and when attach must resolve an uncached persisted id; deletion or cwd damage performed by another process is observed after the next refresh or restart. diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 6bfab64773..8ef0c34382 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/workspace/workspace/src/entity.ts b/packages/workspace/workspace/src/entity.ts index 6ce2c01a00..7f213db0b8 100644 --- a/packages/workspace/workspace/src/entity.ts +++ b/packages/workspace/workspace/src/entity.ts @@ -2,7 +2,7 @@ * Package-private workspace entity: the single {@link Workspace} * implementation. Holds a record snapshot that is swapped in place after each * durable mutation; every write funnels through the private `mutate` so - * `updatedAt` stamping and dead-account pruning happen exactly once. + * `updatedAt` stamping and invalid-account pruning happen exactly once. * Not re-exported from the package entrypoint — consumers see only the * `Workspace` interface. * @module @deepseek-ai/dsh-workspace/src/entity @@ -17,8 +17,8 @@ import { realpathNormalize } from './paths.ts' /** * The registry-owned machinery an entity mutates through. Entities never see - * the registry itself — only the open table, the known-session view backing - * the `sessionIds` projection, and header reads for attach validation. + * the registry itself — only the open table, the canonical session-path + * index backing the `sessionIds` projection, and attach-time header reads. */ export interface WorkspaceEntityHost { /** @@ -28,13 +28,12 @@ export interface WorkspaceEntityHost { table(): KvTable /** - * Synchronous view of the session ids known to exist in session - * persistence. - * @returns the id set, or `undefined` when persistence has been absent so - * far (membership cannot be verified, so projections serve the account - * unfiltered). + * Read a session's canonical directory from the registry's header index. + * @param id - Session whose indexed path is requested. + * @returns the canonical directory, or `undefined` when the header is + * missing or its cwd cannot identify an existing directory. */ - knownSessionIds(): ReadonlySet | undefined + sessionPath(id: SessionId): string | undefined /** * Read one stored session header for attach validation. @@ -43,6 +42,13 @@ export interface WorkspaceEntityHost { * no session with this id. */ readSessionHeader(id: SessionId): Promise + + /** + * Publish a successfully validated canonical cwd to the projection index. + * @param id - Validated session id. + * @param path - Canonical existing directory from the immutable header cwd. + */ + rememberSessionPath(id: SessionId, path: string): void } /** Chain-slot abort sentinel thrown by the update fn when the record needs no change; only `mutate` observes it. */ @@ -53,7 +59,7 @@ export class WorkspaceEntity implements Workspace { private record: WorkspaceRecord /** - * @param host - Registry-owned table, known-session view, and header reads. + * @param host - Registry-owned table, session-path index, and header reads. * @param id - The record's stable id. * @param record - The validated record snapshot loaded or just written. */ @@ -73,10 +79,16 @@ export class WorkspaceEntity implements Workspace { return this.record.title } + get createdAt(): string { + return this.record.createdAt + } + + get updatedAt(): string { + return this.record.updatedAt + } + get sessionIds(): readonly SessionId[] { - const known = this.host.knownSessionIds() - if (known === undefined) return this.record.sessionIds - return this.record.sessionIds.filter(id => known.has(id)) + return this.record.sessionIds.filter(id => this.host.sessionPath(id) === this.record.path) } async setTitle(title: string): Promise { @@ -106,16 +118,49 @@ export class WorkspaceEntity implements Workspace { { cause: error }, ) } + if (!(await stat(cwd)).isDirectory()) { + throw new Error( + `cannot attach session '${sessionId}' to workspace '${this.record.path}': ` + + `its cwd '${header.cwd}' is not a directory`, + ) + } if (cwd !== this.record.path) { throw new Error( `cannot attach session '${sessionId}' to workspace '${this.record.path}': ` + `its cwd resolves to '${cwd}'`, ) } + this.host.rememberSessionPath(sessionId, cwd) } await this.mutate(record => record.sessionIds.includes(sessionId) ? record - : { ...record, sessionIds: [...record.sessionIds, sessionId] }) + : { ...record, sessionIds: [sessionId, ...record.sessionIds] }) + } + + /** + * Test the durable candidate account without applying header projection. + * @param sessionId - Candidate session id. + * @returns whether this workspace's stored account contains the id. + */ + hasSession(sessionId: SessionId): boolean { + return this.record.sessionIds.includes(sessionId) + } + + /** + * Move one validated accounted session to the front without touching peers. + * @param sessionId - Accounted session whose activity was observed. + */ + async touchSession(sessionId: SessionId): Promise { + if ( + this.host.sessionPath(sessionId) !== this.record.path + || this.record.sessionIds[0] === sessionId + ) return + await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId + ? record + : { + ...record, + sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)], + }) } async detachSession(sessionId: SessionId): Promise { @@ -136,9 +181,9 @@ export class WorkspaceEntity implements Workspace { /** * The single write path: run `fn` on the domain write chain via - * `table.update`, stamping `updatedAt` and pruning accounted ids whose - * session no longer exists (consistency rule: dead ids are dropped on the - * next mutation, whatever that mutation is), then swap the snapshot. + * `table.update`, stamping `updatedAt` and pruning candidates that no + * longer pass the id-plus-canonical-cwd membership check, then swap the + * snapshot. * * `fn` sees the value current at its chain slot, so membership decisions * (attach/detach idempotence) are race-free against queued writes; a fn @@ -147,14 +192,13 @@ export class WorkspaceEntity implements Workspace { * rewrites the medium nor emits a change event. */ private async mutate(fn: (record: WorkspaceRecord) => WorkspaceRecord): Promise { - const known = this.host.knownSessionIds() let next: WorkspaceRecord try { next = await this.host.table().update(this.id, (current) => { const changed = fn(current) - const sessionIds = known === undefined - ? changed.sessionIds - : changed.sessionIds.filter(id => known.has(id)) + const sessionIds = changed.sessionIds.filter( + id => this.host.sessionPath(id) === changed.path, + ) if (changed === current && sessionIds.length === current.sessionIds.length) { throw unchangedSentinel } diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 3754a9917a..c20c2143c6 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -1,8 +1,7 @@ /** - * Workspace entity registry (`ctx.workspace`): durable workspace records over - * the domain data form, with session attachment validated against stored - * session headers. This package owns the `WorkspaceId` brand and the - * `workspace` domain; consumers see the {@link Workspace} interface only. + * Workspace entity registry (`ctx.workspace`): durable workspace records, + * stable registry order, and header-validated session membership over the + * domain data form. * @module @deepseek-ai/dsh-workspace */ @@ -11,20 +10,18 @@ import { stat } from 'node:fs/promises' import { basename } from 'node:path' import { Context, Service } from 'cordis' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -// Type-only: merges `sessionPersistence` into the Context service map for the -// optional `ctx.get` lookups below. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { KvTable } from '@deepseek-ai/dsh-storage-domain' -import { workspaceDomainSpec } from './spec.ts' -import type { WorkspaceRecord } from './spec.ts' +import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain' import { WorkspaceEntity } from './entity.ts' import type { WorkspaceEntityHost } from './entity.ts' import { realpathNormalize } from './paths.ts' +import { workspaceDomainSpec } from './spec.ts' +import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts' export type { Workspace } from './types.ts' -export { workspaceRecord, workspaceDomainSpec } from './spec.ts' -export type { WorkspaceRecord } from './spec.ts' +export { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from './spec.ts' +export type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' export { realpathNormalize } from './paths.ts' /** Identifies one workspace record (see `src/types.ts` for the brand rationale). */ @@ -32,74 +29,345 @@ export type WorkspaceId = WorkspaceIdBrand /** * Brand a string as a {@link WorkspaceId}. - * @param id - the raw workspace id string. - * @returns the same string, branded (a compile-time cast — no runtime cost). + * @param id - Raw workspace id string. + * @returns the same string, branded at compile time. */ export function WorkspaceId(id: string): WorkspaceId { return id as WorkspaceId } +/** A create request would give two Workspaces the same display name. */ +export class WorkspaceNameConflictError extends Error { + /** + * @param workspaceName - Conflicting display name. + */ + constructor(readonly workspaceName: string) { + super(`workspace name '${workspaceName}' is already in use`) + this.name = 'WorkspaceNameConflictError' + } +} + declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } } +interface BootstrapGroup { + readonly path: string + readonly headers: SessionHeader[] + readonly newestAt: number +} + +const sameIds = (left: readonly WorkspaceId[], right: readonly WorkspaceId[]): boolean => + left.length === right.length && left.every((id, index) => id === right[index]) + +const compareHeaders = (left: SessionHeader, right: SessionHeader): number => + right.createdAt - left.createdAt || String(left.id).localeCompare(String(right.id)) + /** - * The workspace registry service. Opens the `workspace` domain at startup, - * rebuilds one entity per stored record, and serves entities from an - * in-memory cache keyed by id. Session persistence is an OPTIONAL peer - * (resolved via `ctx.get`, never injected): while it is absent, session - * attachment rejects (what cannot be validated is not recorded) and - * `sessionIds` projections serve the account unfiltered. - * - * There is deliberately no delete entry point in this phase: workspace - * deletion ships as one complete semantic together with the session-cascade - * primitives (future work in the owning Agent Note). + * Durable workspace registry. Startup waits for `sessionPersistence`, builds + * one canonical-cwd header index, and completes the one-time history + * bootstrap before the service becomes active. The persistence dependency is + * mandatory so an unavailable peer can never be mistaken for an empty + * history and commit the initialized marker. */ export class WorkspaceRegistry extends Service { - static inject = ['storage'] + static inject = ['storageDomain', 'sessionPersistence'] private table?: KvTable + private global?: DomainGlobal + private state?: WorkspaceDomainState private readonly entities = new Map() - /** - * Session ids known to exist in session persistence; `undefined` until the - * first successful listing. Refreshed at startup and on every attach - * validation — within one process sessions are only ever added (this phase - * has no delete primitive), so the set can only lag by missing very recent - * sessions, never by holding dead ones from this process's lifetime. - */ - private known?: Set + private readonly headers = new Map() + private readonly sessionPaths = new Map() + private readonly invalidSessionPaths = new Map() + private readonly pendingTouches = new Map>() + private operationTail: Promise = Promise.resolve() private readonly host: WorkspaceEntityHost = { table: () => this.requireTable(), - knownSessionIds: () => this.known, + sessionPath: id => this.sessionPaths.get(id), readSessionHeader: id => this.readSessionHeader(id), + rememberSessionPath: (id, path) => { + this.sessionPaths.set(id, path) + this.invalidSessionPaths.delete(id) + }, } constructor(ctx: Context) { super(ctx, 'workspace') } - /** Open the domain and rebuild the entity cache before the service is published as active. */ + /** Open the domain, finish bootstrap when required, and rebuild the ordered cache. */ protected async [Service.init](): Promise { - const domain = await this.ctx.storage.domain.open(workspaceDomainSpec) - // This registry owns the domain handle it opened: closing on fiber - // disposal frees the domain name, so a re-plugged registry can reopen it. + const domain = await this.ctx.storageDomain.open(workspaceDomainSpec) this.ctx.effect(() => () => domain.close(), 'workspace.domainClose') this.table = domain.table('workspaces') - const persistence = this.ctx.get('sessionPersistence') - if (persistence !== undefined) { - this.known = new Set((await persistence.list()).map(header => header.id)) + this.global = domain.global + this.state = domain.global.get() + + this.validateStoredState(this.state) + if (!this.state.initialized) { + const headers = await this.ctx.sessionPersistence.list() + await this.replaceHeaderIndex(headers) + await this.bootstrap(headers) + } else if (this.table.size > 0) { + await this.replaceHeaderIndex(await this.ctx.sessionPersistence.list()) } - // Rebuild entities, rejecting states the write side makes structurally - // impossible (an external medium edit is the only way in, and hiding it - // would silently pick a winner): one session accounted under two - // workspaces, or two records claiming one canonical path (plain string - // equality — stored paths are already canonical, so no realpath here). - const accounted = new Map() + + await this.indexLiveSessions() + this.validateStoredState(this.requireState()) + this.rebuildEntities() + this.reportFilteredCandidates() + // Session activity is authoritative even when no RPC/SSE consumer is + // connected. This service-owned listener is disposed with the registry. + this.ctx.on('session/event', (session) => { + void this.touchSession(session.id).catch((error: unknown) => { + this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`) + }) + }) + } + + /** + * Create or reuse a workspace for an existing directory. The path is + * canonicalized through `fs.realpath`; a nonexistent path rejects with the + * original error and a non-directory rejects. Repeated calls for the same + * canonical path return the existing entity without changing its title. + * A newly created workspace is prepended to the durable registry order. + * A different canonical path cannot create a duplicate display title. + * @param path - Existing directory to own, in any path spelling. + * @param title - Display title used only when a new record is created. + * @returns the existing or newly durable workspace. + */ + async create(path: string, title?: string): Promise { + const canonical = await realpathNormalize(path) + if (!(await stat(canonical)).isDirectory()) { + throw new Error(`cannot create a workspace at '${canonical}': path is not a directory`) + } + return await this.enqueueOperation(() => this.createCanonical(canonical, title)) + } + + /** + * Look up a workspace by id. + * @param id - Workspace id. + * @returns the workspace, or `undefined` when unknown. + */ + get(id: WorkspaceId): Workspace | undefined { + return this.entities.get(id) + } + + /** + * Synchronous workspace projection in durable registry order. Every + * entity's `sessionIds` getter is already filtered by the startup/live + * canonical-cwd header index; this method performs no persistence reads. + * @returns a fresh ordered array of workspace entities. + */ + list(): Workspace[] { + return this.requireState().workspaceIds.map((id) => { + const entity = this.entities.get(id) + if (entity === undefined) { + throw new Error(`workspace registry order references missing workspace '${id}'`) + } + return entity + }) + } + + /** + * Move one accounted, cwd-validated session to the front of its workspace. + * Ungrouped sessions and candidates filtered by the header check are + * no-ops. The owning workspace's relative position never changes. + * @param sessionId - Session whose activity was observed. + * @returns resolution after the possible record write. + */ + async touchSession(sessionId: SessionId): Promise { + const pending = this.pendingTouches.get(sessionId) + if (pending !== undefined) { + await pending + return + } + for (const entity of this.entities.values()) { + if (!entity.hasSession(sessionId)) continue + const touch = entity.touchSession(sessionId) + this.pendingTouches.set(sessionId, touch) + try { + await touch + } finally { + this.pendingTouches.delete(sessionId) + } + return + } + } + + /** + * Resolve by canonical directory path without creating or mutating a + * workspace. A missing path rejects during `realpath`; an existing unowned + * directory returns `undefined`. + * @param path - Existing directory path in any spelling. + * @returns the workspace owning the canonical path, when one exists. + */ + async resolveByPath(path: string): Promise { + const canonical = await realpathNormalize(path) + for (const entity of this.entities.values()) { + if (entity.path === canonical) return entity + } + return undefined + } + + private async createCanonical(canonical: string, title?: string): Promise { + for (const entity of this.entities.values()) { + if (entity.path === canonical) return entity + } + + const workspaceName = title ?? basename(canonical) + if ([...this.entities.values()].some(entity => entity.title === workspaceName)) { + throw new WorkspaceNameConflictError(workspaceName) + } + + const table = this.requireTable() + const state = this.requireState() + const id = WorkspaceId(randomUUID()) + const now = new Date().toISOString() + const record: WorkspaceRecord = { + path: canonical, + title: workspaceName, + sessionIds: [], + createdAt: now, + updatedAt: now, + } + const entity = new WorkspaceEntity(this.host, id, record) + this.entities.set(id, entity) + try { + await table.put(id, record) + } catch (error) { + this.entities.delete(id) + throw error + } + + try { + await this.setState({ initialized: true, workspaceIds: [id, ...state.workspaceIds] }) + } catch (error) { + this.entities.delete(id) + try { + await table.delete(id) + } catch (rollbackError) { + this.entities.set(id, entity) + throw new AggregateError( + [error, rollbackError], + `workspace '${id}' was stored but its registry order and rollback both failed`, + ) + } + throw error + } + return entity + } + + private async bootstrap(headers: readonly SessionHeader[]): Promise { + const table = this.requireTable() + const state = this.requireState() + const groupsByPath = new Map() + for (const header of headers) { + const path = this.sessionPaths.get(header.id) + if (path === undefined) continue + const group = groupsByPath.get(path) + if (group === undefined) groupsByPath.set(path, [header]) + else group.push(header) + } + const groups: BootstrapGroup[] = [...groupsByPath].map(([path, groupHeaders]) => { + groupHeaders.sort(compareHeaders) + const newest = groupHeaders[0] as SessionHeader + return { path, headers: groupHeaders, newestAt: newest.createdAt } + }).sort((left, right) => + right.newestAt - left.newestAt || left.path.localeCompare(right.path)) + + const byPath = new Map() + const accounted = new Map() + for (const [id, record] of table.entries()) { + byPath.set(record.path, id) + for (const sessionId of record.sessionIds) accounted.set(sessionId, id) + } + + for (const group of groups) { + let id = byPath.get(group.path) + if (id === undefined) { + const sessionIds = group.headers + .map(header => header.id) + .filter(sessionId => !accounted.has(sessionId)) + if (sessionIds.length === 0) continue + id = WorkspaceId(randomUUID()) + const createdAt = new Date(group.newestAt).toISOString() + const record: WorkspaceRecord = { + path: group.path, + title: basename(group.path), + sessionIds, + createdAt, + updatedAt: createdAt, + } + await table.put(id, record) + byPath.set(group.path, id) + for (const sessionId of sessionIds) accounted.set(sessionId, id) + continue + } + + const current = table.get(id) as WorkspaceRecord + const historical = group.headers + .map(header => header.id) + .filter(sessionId => accounted.get(sessionId) === undefined || accounted.get(sessionId) === id) + const historicalSet = new Set(historical) + const sessionIds = [ + ...historical, + ...current.sessionIds.filter(sessionId => !historicalSet.has(sessionId)), + ] + if (sameSessionIds(current.sessionIds, sessionIds)) continue + await table.update(id, record => ({ + ...record, + sessionIds, + updatedAt: new Date().toISOString(), + })) + for (const sessionId of historical) accounted.set(sessionId, id) + } + + const groupRank = new Map(groups.map(group => [group.path, group.newestAt])) + const priorRank = new Map(state.workspaceIds.map((id, index) => [id, index])) + const workspaceIds = [...table.entries()] + .sort(([leftId, left], [rightId, right]) => { + const leftTime = groupRank.get(left.path) ?? Date.parse(left.createdAt) + const rightTime = groupRank.get(right.path) ?? Date.parse(right.createdAt) + return rightTime - leftTime + || (priorRank.get(leftId) ?? Number.MAX_SAFE_INTEGER) + - (priorRank.get(rightId) ?? Number.MAX_SAFE_INTEGER) + || String(leftId).localeCompare(String(rightId)) + }) + .map(([id]) => id) + + if (!sameIds(state.workspaceIds, workspaceIds)) { + await this.setState({ initialized: false, workspaceIds }) + } + await this.setState({ initialized: true, workspaceIds }) + } + + private validateStoredState(state: WorkspaceDomainState): void { + const table = this.requireTable() + const order = new Set() + for (const id of state.workspaceIds) { + if (order.has(id)) { + throw new Error(`workspace domain is inconsistent: registry order repeats workspace '${id}'`) + } + if (table.get(id) === undefined) { + throw new Error(`workspace domain is inconsistent: registry order references missing workspace '${id}'`) + } + order.add(id) + } + if (state.initialized && order.size !== table.size) { + const orphan = [...table.keys()].find(id => !order.has(id)) + throw new Error( + `workspace domain is inconsistent: workspace '${orphan as WorkspaceId}' is absent from registry order`, + ) + } + const paths = new Map() - for (const [id, record] of this.table.entries()) { + const accounted = new Map() + for (const [id, record] of table.entries()) { const pathHolder = paths.get(record.path) if (pathHolder !== undefined) { throw new Error( @@ -118,114 +386,112 @@ export class WorkspaceRegistry extends Service { } accounted.set(sessionId, id) } + } + } + + private rebuildEntities(): void { + this.entities.clear() + for (const id of this.requireState().workspaceIds) { + const record = this.requireTable().get(id) as WorkspaceRecord this.entities.set(id, new WorkspaceEntity(this.host, id, record)) } } - /** - * Create a workspace over an existing directory. The path is canonicalized - * through `fs.realpath` first — a nonexistent path rejects with the - * original `ENOENT`, a path resolving to anything but a directory rejects, - * and a canonical path already owned by another workspace (including a - * symlink resolving to it) rejects. - * @param path - Directory the workspace points at; canonicalized before storing. - * @param title - Display title; defaults to `basename` of the canonical path. - * @returns the created workspace after durability. - */ - async create(path: string, title?: string): Promise { - const table = this.requireTable() - const canonical = await realpathNormalize(path) - if (!(await stat(canonical)).isDirectory()) { - throw new Error(`cannot create a workspace at '${canonical}': path is not a directory`) + private async replaceHeaderIndex(headers: readonly SessionHeader[]): Promise { + this.headers.clear() + this.sessionPaths.clear() + this.invalidSessionPaths.clear() + await this.indexHeaders(headers) + } + + private async indexHeaders(headers: readonly SessionHeader[]): Promise { + for (const header of headers) await this.indexHeader(header) + } + + private async indexHeader(header: SessionHeader): Promise { + this.headers.set(header.id, header) + this.sessionPaths.delete(header.id) + if (header.cwd === undefined) { + this.invalidSessionPaths.set(header.id, 'header has no cwd') + return } + try { + const path = await realpathNormalize(header.cwd) + if (!(await stat(path)).isDirectory()) { + this.invalidSessionPaths.set(header.id, `cwd '${header.cwd}' is not a directory`) + return + } + this.sessionPaths.set(header.id, path) + this.invalidSessionPaths.delete(header.id) + } catch { + this.invalidSessionPaths.set(header.id, `cwd '${header.cwd}' does not resolve`) + } + } + + private async indexLiveSessions(): Promise { + const sessions = this.ctx.get('sessions') + if (sessions === undefined) return + await this.indexHeaders(sessions.list().map(session => session.header)) + } + + private reportFilteredCandidates(): void { for (const entity of this.entities.values()) { - if (entity.path === canonical) { - throw new Error(`a workspace for '${canonical}' already exists ('${entity.id}')`) + const record = this.requireTable().get(entity.id) as WorkspaceRecord + for (const sessionId of record.sessionIds) { + const path = this.sessionPaths.get(sessionId) + if (path === record.path) continue + const reason = this.invalidSessionPaths.get(sessionId) + ?? (this.headers.has(sessionId) + ? `canonical cwd '${path}' differs from workspace path '${record.path}'` + : 'session header is missing') + this.ctx.logger.warn( + `workspace '${entity.id}' filtered session '${sessionId}' from membership: ${reason}`, + ) } } - const id = WorkspaceId(randomUUID()) - const now = new Date().toISOString() - const record: WorkspaceRecord = { - path: canonical, - title: title ?? basename(canonical), - sessionIds: [], - createdAt: now, - updatedAt: now, - } - const entity = new WorkspaceEntity(this.host, id, record) - // Cache before the durable put: a concurrent same-path create fails the - // scan above, and the entity already exists when `domain/changed` fires. - this.entities.set(id, entity) - try { - await table.put(id, record) - } catch (error) { - this.entities.delete(id) - throw error - } - return entity } - /** - * Look up a workspace by id. - * @param id - The workspace id. - * @returns the workspace, or `undefined` when unknown. - */ - get(id: WorkspaceId): Workspace | undefined { - return this.entities.get(id) - } - - /** - * Snapshot of all workspaces, in load-then-creation order. - * @returns a fresh array of the cached entities. - */ - list(): Workspace[] { - return [...this.entities.values()] - } - - /** - * Resolve a workspace by directory path, through the same `fs.realpath` - * canon as {@link create} (hence async). A path that does not exist rejects - * with the original error — a missing directory has no canonical form to - * compare (a workspace whose recorded directory vanished is only reachable - * by id; see `Workspace.status`). - * @param path - Directory path in any spelling (symlinks, `..`, trailing slash). - * @returns the owning workspace, or `undefined` when none matches. - */ - async resolveByPath(path: string): Promise { - const canonical = await realpathNormalize(path) - for (const entity of this.entities.values()) { - if (entity.path === canonical) return entity - } - return undefined - } - - private requireTable(): KvTable { - if (this.table === undefined) { - throw new Error('workspace registry is not started yet') - } - return this.table - } - - /** - * Read one stored session header for attach validation, refreshing the - * known-session view from the same listing. Rejects when session - * persistence is absent or holds no session with this id. - */ private async readSessionHeader(id: SessionId): Promise { - const persistence = this.ctx.get('sessionPersistence') - if (persistence === undefined) { - throw new Error( - `cannot validate session '${id}': no session persistence service is available`, - ) + const live = this.ctx.get('sessions')?.get(id) + if (live !== undefined) { + this.headers.set(id, live.header) + return live.header } - const headers = await persistence.list() - this.known = new Set(headers.map(header => header.id)) - const header = headers.find(candidate => candidate.id === id) + const cached = this.headers.get(id) + if (cached !== undefined) return cached + + const headers = await this.ctx.sessionPersistence.list() + await this.indexHeaders(headers) + const header = this.headers.get(id) if (header === undefined) { throw new Error(`cannot validate session '${id}': session persistence holds no such session`) } return header } + + private requireTable(): KvTable { + if (this.table === undefined) throw new Error('workspace registry is not started yet') + return this.table + } + + private requireState(): WorkspaceDomainState { + if (this.state === undefined) throw new Error('workspace registry is not started yet') + return this.state + } + + private async setState(state: WorkspaceDomainState): Promise { + await (this.global as DomainGlobal).set(state) + this.state = state + } + + private enqueueOperation(operation: () => Promise): Promise { + const result = this.operationTail.then(operation) + this.operationTail = result.then(() => {}, () => {}) + return result + } } +const sameSessionIds = (left: readonly SessionId[], right: readonly SessionId[]): boolean => + left.length === right.length && left.every((id, index) => id === right[index]) + export default WorkspaceRegistry diff --git a/packages/workspace/workspace/src/invariant.ts b/packages/workspace/workspace/src/invariant.ts index 70c9ea4b48..1764ce2fe3 100644 --- a/packages/workspace/workspace/src/invariant.ts +++ b/packages/workspace/workspace/src/invariant.ts @@ -19,19 +19,22 @@ export const inject = ['invariants'] * Owned relationship: the registry's entity cache mirrors the workspace * domain's durable table. Every `domain/changed` for the `workspaces` table * must name a record the cache already holds an entity for (the registry - * caches before the durable put and mutates only through cached entities), - * and no `deleted` operation may appear at all — this phase ships no delete - * entry point, so a deletion proves a write path outside the registry. + * caches before the durable put and mutates only through cached entities). + * A delete is valid only for create rollback, after the provisional cache + * entry has been removed; deleting a published entity proves a bypass. */ const install: InvariantInstaller = Object.assign( (ctx: Context, fail: (message: string) => never) => { ctx.on('domain/changed', (change: DomainChanged) => { if (change.domain !== 'workspace' || change.table !== 'workspaces') return if (change.operation === 'deleted') { - fail( - `workspace record '${change.key}' emitted a deleted change, but the registry ` - + 'exposes no delete entry point — some write path bypassed ctx.workspace', - ) + if (ctx.workspace.get(WorkspaceId(change.key)) !== undefined) { + fail( + `workspace record '${change.key}' was deleted while the registry cache still ` + + 'publishes it — some write path bypassed ctx.workspace', + ) + } + return } if (ctx.workspace.get(WorkspaceId(change.key)) === undefined) { fail( diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts index 7ef1487bd7..8df908949a 100644 --- a/packages/workspace/workspace/src/spec.ts +++ b/packages/workspace/workspace/src/spec.ts @@ -10,6 +10,9 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' import type { WorkspaceId } from './types.ts' +/** Workspace id schema at the durable boundary; branding has no runtime representation. */ +const workspaceId = z.string().transform(value => value as WorkspaceId) + /** * Durable shape of one workspace record. `path` is the `fs.realpath` canon * stamped at create; `sessionIds` is the ordered ownership account (array @@ -26,14 +29,31 @@ export const workspaceRecord = z.object({ /** One stored workspace record, inferred from {@link workspaceRecord}. */ export type WorkspaceRecord = z.infer +/** + * Durable registry state. `initialized` distinguishes a valid empty registry + * from one that still needs the header-only history bootstrap; + * `workspaceIds` is the authoritative display order. + */ +export const workspaceDomainState = z.object({ + initialized: z.boolean(), + workspaceIds: z.array(workspaceId), +}) + +/** Durable registry state inferred from {@link workspaceDomainState}. */ +export type WorkspaceDomainState = z.infer + /** * The workspace domain spec: one `workspaces` table keyed by - * {@link WorkspaceId}, no global singleton. The registry opens this through - * `ctx.storage.domain`; the spec object is the single source of the domain's - * identity, version, and record schema. + * {@link WorkspaceId} plus the bootstrap/order singleton. The registry opens + * this through `ctx.storage.domain`; the spec object is the single source of + * the domain's identity, version, and schemas. */ export const workspaceDomainSpec = defineDomain({ name: 'workspace', - version: 1, + version: 2, + global: { + schema: workspaceDomainState, + initial: { initialized: false, workspaceIds: [] }, + }, tables: { workspaces: domainTable(workspaceRecord) }, }) diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index cc5d75653d..ca254ca2cb 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -16,9 +16,9 @@ export type WorkspaceId = Branded<'WorkspaceId'> /** * One workspace: a stable id over an existing directory, a display title, and - * the ordered account of sessions that belong to it. The account is the sole - * source of ownership — sessions are never inferred from cwd. Consumers only - * see this interface; the entity implementation stays package-private. + * an ordered candidate account of sessions. Membership requires both an id in + * that account and a session header whose canonical cwd equals the workspace + * path. Consumers only see this interface; the implementation stays private. */ export interface Workspace { /** Stable record id (generated uuid). */ @@ -34,13 +34,17 @@ export interface Workspace { /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */ readonly title: string + /** ISO-8601 creation instant, stamped at create and never rewritten. */ + readonly createdAt: string + + /** ISO-8601 instant of the last durable mutation (create counts as one). */ + readonly updatedAt: string + /** - * Sessions recorded under this workspace, in attach order (the array order - * is the display order). A projection: accounted ids whose session no - * longer exists in session persistence are filtered out here (and dropped - * from the durable account on the next mutation); when session persistence - * is absent the account is served unfiltered because membership cannot be - * verified. + * Header-validated sessions in newest-first display order. The durable + * candidate account is filtered synchronously: missing headers, invalid + * cwd values, and canonical cwd mismatches are never returned. A subsequent + * workspace mutation prunes those filtered candidates durably. */ readonly sessionIds: readonly SessionId[] @@ -52,16 +56,12 @@ export interface Workspace { setTitle(title: string): Promise /** - * Record a session under this workspace. Idempotent: a session already on - * the account resolves without writing (membership is decided on the - * domain write chain, so unawaited concurrent attach/detach calls settle - * in call order). For a session not yet on the account, its stored header - * is read from session persistence and its `cwd`, normalized through the - * same `fs.realpath` canon as workspace paths, must equal this workspace's - * {@link path} — a missing persistence service, an unknown session id, a - * header without `cwd`, a `cwd` that no longer resolves, or a mismatched - * `cwd` all reject without touching the account (what cannot be validated - * is not recorded). + * Prepend a session to this workspace's candidate account. An already + * accounted id resolves without writing; activity-driven reordering uses + * `WorkspaceRegistry.touchSession` instead. A new id's live or persisted + * header cwd must resolve to an existing directory equal to {@link path}; + * unknown ids, missing or invalid cwd values, and mismatches reject without + * writing. * @param sessionId - The session to record. * @returns resolution after durability. */ diff --git a/packages/workspace/workspace/tests/invariant.spec.ts b/packages/workspace/workspace/tests/invariant.spec.ts index 583c516937..ea1fbaa64c 100644 --- a/packages/workspace/workspace/tests/invariant.spec.ts +++ b/packages/workspace/workspace/tests/invariant.spec.ts @@ -43,10 +43,15 @@ describe('workspace cache/table invariant', () => { expect(() => { ctx.emit('domain/changed', put({ table: 'other', key: 'missing' })) }).not.toThrow() }) - it('fails a deleted operation — this phase exposes no delete entry point', async () => { + it('fails deletion while the registry still publishes the entity', async () => { const ctx = await setup(['w1']) expect(() => { ctx.emit('domain/changed', deleted()) }) - .toThrow(/no delete entry point/) + .toThrow(/cache still publishes/) + }) + + it('allows deletion only after a provisional create cache entry was removed for rollback', async () => { + const ctx = await setup([]) + expect(() => { ctx.emit('domain/changed', deleted()) }).not.toThrow() }) it('fails a put whose record the registry cache does not hold', async () => { diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 9d2c71c5cc..cde0b35098 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' @@ -7,103 +7,158 @@ import Storage from '@deepseek-ai/dsh-storage' import type { StorageBackend } from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' -import { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId } from '../src/index.ts' -import type { WorkspaceRecord } from '../src/index.ts' +import { WorkspaceEntity } from '../src/entity.ts' +import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts' +import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' -const header = (id: string, cwd?: string): SessionHeader => - ({ version: 0, id: SessionId(id), createdAt: 0, ...(cwd === undefined ? {} : { cwd }) }) +const DOMAIN_VERSION = 2 -/** - * Boot storage hub + memory backend + domain form + the workspace registry. - * `sessions: 'absent'` boots without a sessionPersistence service; otherwise - * a stub serving exactly the given headers from `list()` is provided, and - * `setSessions` swaps what it serves next. - */ -async function harness(options?: { +const header = (id: string, cwd?: string, createdAt = 0): SessionHeader => ({ + version: 0, + id: SessionId(id), + createdAt, + ...(cwd === undefined ? {} : { cwd }), +}) + +interface HarnessOptions { pool?: MemoryMediaPool - sessions?: SessionHeader[] | 'absent' + sessions?: SessionHeader[] + liveSessions?: SessionHeader[] + sessionStore?: boolean backend?: StorageBackend -}) { +} + +/** Boot the real storage/domain/registry composition over controllable header-only peers. */ +async function harness(options: HarnessOptions = {}) { + const pool = options.pool ?? new MemoryMediaPool() const ctx = new Context() await ctx.plugin(Storage) - ctx.storage.backend.register('memory', options?.backend ?? new MemoryStorageBackend(options?.pool)) - ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} })) - let listed = options?.sessions === 'absent' ? undefined : options?.sessions ?? [] - if (listed !== undefined) { - ctx.provide('sessionPersistence', { list: async () => listed ?? [] }) + ctx.storage.backend.register('memory', options.backend ?? new MemoryStorageBackend(pool)) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + + let listed = options.sessions ?? [] + const list = vi.fn(async () => listed) + const load = vi.fn(() => { throw new Error('event bodies must not be loaded') }) + const inspect = vi.fn(() => { throw new Error('event bodies must not be inspected') }) + ctx.provide('sessionPersistence', { list, load, inspect } as never) + + if (options.sessionStore === true) { + await ctx.plugin(SessionStore) + } else if (options.liveSessions !== undefined) { + const live = new Map(options.liveSessions.map(meta => [meta.id, { header: meta }])) + ctx.provide('sessions', { + get: (id: SessionId) => live.get(id), + list: () => [...live.values()], + } as never) } + const changes: DomainChanged[] = [] ctx.on('domain/changed', (change) => { changes.push(change) }) - await ctx.plugin(WorkspaceRegistry) + const fiber = await ctx.plugin(WorkspaceRegistry) + const initChanges = [...changes] + changes.length = 0 return { ctx, + fiber, + pool, registry: ctx.workspace, changes, + initChanges, + list, + load, + inspect, setSessions: (headers: SessionHeader[]) => { listed = headers }, } } -/** A memory backend whose next `putRecord` throws once when armed, for write-failure paths. */ -function failingBackend(): { backend: StorageBackend; arm: () => void } { - const inner = new MemoryStorageBackend() - let failNext = false +/** Boot only the storage side, for dependency-pending and startup-failure cases. */ +async function storageContext(pool: MemoryMediaPool, backend: StorageBackend = new MemoryStorageBackend(pool)) { + const ctx = new Context() + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', backend) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + return ctx +} + +/** Backend wrapper that injects one selected bootstrap write failure. */ +function selectiveFailureBackend( + pool: MemoryMediaPool, + failure: { putAt?: number; deleteAt?: number; globalAt?: number }, +): StorageBackend { + const inner = new MemoryStorageBackend(pool) + let puts = 0 + let deletes = 0 + let globals = 0 return { - arm: () => { failNext = true }, - backend: { - kv: { - open: async (descriptor) => { - const unit = await inner.kv.open(descriptor) - return { - loadAll: () => unit.loadAll(), - putRecord: async (table, key, value) => { - if (failNext) { - failNext = false - throw new Error('medium write failed (injected)') - } - return unit.putRecord(table, key, value) - }, - deleteRecord: (table, key) => unit.deleteRecord(table, key), - setGlobal: value => unit.setGlobal(value), - close: () => unit.close(), - } - }, + kv: { + open: async (descriptor) => { + const unit = await inner.kv.open(descriptor) + return { + loadAll: () => unit.loadAll(), + putRecord: async (table, key, value) => { + puts += 1 + if (puts === failure.putAt) throw new Error('selected bootstrap put failure') + await unit.putRecord(table, key, value) + }, + deleteRecord: async (table, key) => { + deletes += 1 + if (deletes === failure.deleteAt) throw new Error('selected rollback delete failure') + await unit.deleteRecord(table, key) + }, + setGlobal: async (value) => { + globals += 1 + if (globals === failure.globalAt) throw new Error('selected bootstrap marker failure') + await unit.setGlobal(value) + }, + close: () => unit.close(), + } }, - close: () => inner.close(), }, + close: () => inner.close(), } } -/** A pool pre-stamped with one stored workspace record, simulating a prior run. */ -function pooledRecord(id: string, record: WorkspaceRecord): MemoryMediaPool { +function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:00:00.000Z'): WorkspaceRecord { + return { + path, + title: basename(path), + sessionIds: sessionIds.map(SessionId), + createdAt, + updatedAt: createdAt, + } +} + +function storedPool( + entries: Array<[string, WorkspaceRecord]>, + state: WorkspaceDomainState, +): MemoryMediaPool { const pool = new MemoryMediaPool() - pool.versions.set('workspace', 1) + pool.versions.set('workspace', DOMAIN_VERSION) pool.media.set('workspace', { - tables: new Map([['workspaces', new Map([[id, record]])]]), - global: null, + tables: new Map([['workspaces', new Map(entries)]]), + global: state, }) return pool } -const record = (path: string, sessionIds: string[]): WorkspaceRecord => ({ - path, - title: basename(path), - sessionIds: sessionIds.map(SessionId), - createdAt: '2026-07-24T00:00:00.000Z', - updatedAt: '2026-07-24T00:00:00.000Z', -}) - -/** Stored record as the memory medium currently holds it. */ function storedRecord(pool: MemoryMediaPool, id: string): WorkspaceRecord { return pool.media.get('workspace')!.tables.get('workspaces')!.get(id) as WorkspaceRecord } +function storedState(pool: MemoryMediaPool): WorkspaceDomainState { + return pool.media.get('workspace')!.global as WorkspaceDomainState +} + let base: string const tempDirs: string[] = [] -/** A fresh real directory under a canonicalized temp base. */ async function makeDir(name: string): Promise { base ??= await realpath(await mkdtemp(join(tmpdir(), 'dsh-workspace-'))) if (tempDirs.length === 0) tempDirs.push(base) @@ -117,265 +172,540 @@ afterEach(async () => { base = undefined as never }) -describe('WorkspaceRegistry.create', () => { - it('stores the canonical path, defaults the title to basename, and lists the entity', async () => { - const dir = await makeDir('proj') - const { registry } = await harness() - const workspace = await registry.create(dir + '/') - expect(workspace.path).toBe(dir) - expect(workspace.title).toBe('proj') - expect(workspace.sessionIds).toEqual([]) - expect(registry.list()).toEqual([workspace]) - expect(registry.get(workspace.id)).toBe(workspace) - const titled = await registry.create(await makeDir('other'), 'Custom') - expect(titled.title).toBe('Custom') +describe('WorkspaceRegistry lifecycle and bootstrap', () => { + it('stays pending without sessionPersistence and never opens or marks the domain', async () => { + const pool = new MemoryMediaPool() + const ctx = await storageContext(pool) + const fiber = await ctx.plugin(WorkspaceRegistry) + expect(ctx.get('workspace')).toBeUndefined() + expect(pool.media.has('workspace')).toBe(false) + + const list = vi.fn(async () => [] as SessionHeader[]) + ctx.provide('sessionPersistence', { list } as never) + await fiber.await() + expect(ctx.workspace.list()).toEqual([]) + expect(list).toHaveBeenCalledTimes(1) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] }) }) - it('rejects a nonexistent directory with the original ENOENT', async () => { - const dir = await makeDir('exists') - const { registry } = await harness() - await expect(registry.create(join(dir, 'nope'))).rejects.toMatchObject({ code: 'ENOENT' }) - expect(registry.list()).toEqual([]) + it('bootstraps once from list headers only, in workspace/session createdAt order', async () => { + const older = await makeDir('older') + const newer = await makeDir('newer') + const alias = join(base, 'older-link') + const plain = join(base, 'plain.txt') + await symlink(older, alias) + await writeFile(plain, 'not a directory') + const missing = join(base, 'missing') + const result = await harness({ + sessions: [ + header('older-first', older, 100), + header('newer-only', newer, 500), + header('older-latest', alias, 300), + header('no-cwd', undefined, 900), + header('missing-dir', missing, 800), + header('plain-file', plain, 700), + ], + }) + + expect(result.list).toHaveBeenCalledTimes(1) + expect(result.load).not.toHaveBeenCalled() + expect(result.inspect).not.toHaveBeenCalled() + expect(result.registry.list().map(workspace => workspace.path)).toEqual([newer, older]) + expect(result.registry.list().map(workspace => workspace.sessionIds)).toEqual([ + ['newer-only'], + ['older-latest', 'older-first'], + ]) + expect(storedState(result.pool)).toEqual({ + initialized: true, + workspaceIds: result.registry.list().map(workspace => workspace.id), + }) }) - it('rejects a path resolving to a plain file', async () => { - const dir = await makeDir('has-file') - const file = join(dir, 'plain.txt') - await writeFile(file, 'not a directory') - const { registry } = await harness() - await expect(registry.create(file)).rejects.toThrow(/not a directory/) - expect(registry.list()).toEqual([]) + it('breaks equal bootstrap timestamps by session id and canonical path', async () => { + const first = await makeDir('tie-first') + const second = await makeDir('tie-second') + const result = await harness({ + sessions: [ + header('z-session', first, 100), + header('a-session', first, 100), + header('second-session', second, 100), + ], + }) + expect(new Set(result.registry.list().map(workspace => workspace.path))).toEqual(new Set([first, second])) + expect(result.registry.list().find(workspace => workspace.path === first)!.sessionIds) + .toEqual(['a-session', 'z-session']) }) - it('rejects a duplicate path, including a symlink resolving to an existing workspace', async () => { - const dir = await makeDir('real') - const link = join(base, 'link') - await symlink(dir, link) - const { registry } = await harness() - await registry.create(dir) - await expect(registry.create(link)).rejects.toThrow(/already exists/) - expect(registry.list()).toHaveLength(1) + it('does not rerun bootstrap for a genuinely initialized empty registry', async () => { + const late = await makeDir('late-cwd-only') + const pool = new MemoryMediaPool() + const first = await harness({ pool, sessions: [] }) + expect(first.list).toHaveBeenCalledTimes(1) + await first.fiber.dispose() + + const second = await harness({ pool, sessions: [header('late', late, 100)] }) + expect(second.list).not.toHaveBeenCalled() + expect(second.registry.list()).toEqual([]) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] }) }) - it('resolves by path through the same canon', async () => { - const dir = await makeDir('canon') - const link = join(base, 'canon-link') - await symlink(dir, link) - const { registry } = await harness() - const workspace = await registry.create(dir) - expect(await registry.resolveByPath(link)).toBe(workspace) - expect(await registry.resolveByPath(await makeDir('unowned'))).toBeUndefined() + it('reuses partial records after a bootstrap record write fails', async () => { + const firstDir = await makeDir('partial-first') + const secondDir = await makeDir('partial-second') + const sessions = [header('first', firstDir, 200), header('second', secondDir, 100)] + const pool = new MemoryMediaPool() + await expect(harness({ + pool, + sessions, + backend: selectiveFailureBackend(pool, { putAt: 2 }), + })).rejects.toThrow(/selected bootstrap put failure/) + expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1) + expect(pool.media.get('workspace')!.global).toBeNull() + + const retried = await harness({ pool, sessions }) + expect(retried.registry.list()).toHaveLength(2) + expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(2) + expect(storedState(pool).initialized).toBe(true) }) - it('rolls the entity cache back when the durable write fails, leaving the path free to retry', async () => { - const dir = await makeDir('rollback') - const { backend, arm } = failingBackend() - const { registry } = await harness({ backend }) - arm() - await expect(registry.create(dir)).rejects.toThrow(/injected/) - expect(registry.list()).toEqual([]) - const retried = await registry.create(dir) - expect(retried.path).toBe(dir) + it('reuses durable order when the final initialized marker write fails', async () => { + const dir = await makeDir('marker-retry') + const sessions = [header('session', dir, 100)] + const pool = new MemoryMediaPool() + await expect(harness({ + pool, + sessions, + backend: selectiveFailureBackend(pool, { globalAt: 2 }), + })).rejects.toThrow(/selected bootstrap marker failure/) + expect(storedState(pool)).toMatchObject({ initialized: false }) + expect(storedState(pool).workspaceIds).toHaveLength(1) + + const retried = await harness({ pool, sessions }) + expect(retried.registry.list()).toHaveLength(1) + expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1) + expect(storedState(pool).initialized).toBe(true) }) - it('rejects any table access before the registry has started', async () => { - const dir = await makeDir('unstarted') - const ctx = new Context() - // Constructed directly, Service.init never ran: no domain, no table. - const registry = new WorkspaceRegistry(ctx) - await expect(registry.create(dir)).rejects.toThrow(/not started/) + it('merges partial records and leaves an already-accounted cwd drift ungrouped', async () => { + const owned = await makeDir('partial-owned') + const prior = await makeDir('partial-prior') + const drifted = await makeDir('partial-drifted') + const ownedId = WorkspaceId('00000000-0000-4000-8000-000000000010') + const priorId = WorkspaceId('00000000-0000-4000-8000-000000000011') + const pool = storedPool( + [ + [ownedId, record(owned, ['old'], '2026-07-24T00:00:00.000Z')], + [priorId, record(prior, ['drift'], '2026-07-23T00:00:00.000Z')], + ], + { initialized: false, workspaceIds: [] }, + ) + const result = await harness({ + pool, + sessions: [header('new', owned, 200), header('old', owned, 100), header('drift', drifted, 300)], + }) + expect(result.registry.list().map(workspace => workspace.id)).toContain(ownedId) + expect(result.registry.get(ownedId)!.sessionIds).toEqual(['new', 'old']) + expect(result.registry.list().some(workspace => workspace.path === drifted)).toBe(false) }) - it('closes its domain on fiber disposal so a re-plugged registry reopens it', async () => { + it('orders headerless partial records by prior order, then stable id', async () => { + const first = await makeDir('fallback-first') + const second = await makeDir('fallback-second') + const firstId = WorkspaceId('00000000-0000-4000-8000-000000000020') + const secondId = WorkspaceId('00000000-0000-4000-8000-000000000021') + const entries: Array<[string, WorkspaceRecord]> = [ + [secondId, record(second, [], '2026-07-24T00:00:00.000Z')], + [firstId, record(first, [], '2026-07-24T00:00:00.000Z')], + ] + const prior = await harness({ + pool: storedPool(entries, { initialized: false, workspaceIds: [secondId, firstId] }), + }) + expect(prior.registry.list().map(workspace => workspace.id)).toEqual([secondId, firstId]) + + const byId = await harness({ + pool: storedPool(entries, { initialized: false, workspaceIds: [] }), + }) + expect(byId.registry.list().map(workspace => workspace.id)).toEqual([firstId, secondId]) + }) + + it('closes its domain on disposal and reloads the persisted stable order', async () => { const dir = await makeDir('replug') - const ctx = new Context() - await ctx.plugin(Storage) - ctx.storage.backend.register('memory', new MemoryStorageBackend()) - ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} })) - const fiber = ctx.plugin(WorkspaceRegistry) - await fiber - const first = await ctx.workspace.create(dir) - await fiber.dispose() - // The registry's effect closed the domain, freeing the name: a second - // plugin of the same registry must reopen it (not already-open) and see - // the durable record. - await ctx.plugin(WorkspaceRegistry) - const reloaded = await ctx.workspace.resolveByPath(dir) - expect(reloaded?.id).toBe(first.id) + const result = await harness() + const first = await result.registry.create(dir) + await result.fiber.dispose() + const nextFiber = await result.ctx.plugin(WorkspaceRegistry) + expect(result.ctx.workspace.list().map(workspace => workspace.id)).toEqual([first.id]) + await nextFiber.dispose() }) }) -describe('Workspace.attachSession', () => { - it('attaches when the session cwd resolves to the workspace path, keeping attach order', async () => { - const dir = await makeDir('attach') - const link = join(base, 'attach-link') - await symlink(dir, link) - // s2's cwd is spelled through the symlink: same canon, must attach. - const { registry } = await harness({ - sessions: [header('s1', dir), header('s2', link), header('s3', dir)], - }) - const workspace = await registry.create(dir) - await workspace.attachSession(SessionId('s1')) - await workspace.attachSession(SessionId('s2')) - await workspace.attachSession(SessionId('s3')) - expect(workspace.sessionIds).toEqual(['s1', 's2', 's3']) - await workspace.detachSession(SessionId('s2')) - expect(workspace.sessionIds).toEqual(['s1', 's3']) +describe('WorkspaceRegistry create and lookup', () => { + it('creates newest-first and idempotently reuses a canonical path without retitling', async () => { + const firstDir = await makeDir('first') + const secondDir = await makeDir('second') + const alias = join(base, 'first-link') + await symlink(firstDir, alias) + const { registry, pool } = await harness() + const first = await registry.create(firstDir, 'Original') + const second = await registry.create(secondDir) + const reused = await registry.create(alias, 'Ignored') + expect(reused).toBe(first) + expect(first.title).toBe('Original') + expect(registry.list()).toEqual([second, first]) + expect(storedState(pool).workspaceIds).toEqual([second.id, first.id]) + expect(await registry.resolveByPath(alias)).toBe(first) + expect(await registry.resolveByPath(await makeDir('unowned'))).toBeUndefined() }) - it('rejects a cwd resolving elsewhere, a missing cwd, and an unknown session', async () => { + it('serializes concurrent same-path creates into one entity', async () => { + const dir = await makeDir('concurrent') + const { registry, pool } = await harness() + const [left, right] = await Promise.all([ + registry.create(dir, 'Winner'), + registry.create(dir, 'Loser'), + ]) + expect(left).toBe(right) + expect(registry.list()).toEqual([left]) + expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1) + }) + + it('rejects a duplicate display name on a different canonical path', async () => { + const firstDir = await makeDir('named-first') + const secondDir = await makeDir('named-second') + const { registry } = await harness() + await registry.create(firstDir, 'Shared') + await expect(registry.create(secondDir, 'Shared')).rejects.toEqual( + expect.objectContaining>({ + workspaceName: 'Shared', + }), + ) + expect(registry.list()).toHaveLength(1) + }) + + it('rejects nonexistent and non-directory paths without changing order', async () => { + const parent = await makeDir('invalid') + const file = join(parent, 'plain.txt') + await writeFile(file, 'file') + const { registry } = await harness() + await expect(registry.create(join(parent, 'missing'))).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(registry.create(file)).rejects.toThrow(/not a directory/) + await expect(registry.resolveByPath(join(parent, 'missing'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(registry.list()).toEqual([]) + }) + + it('rolls back the provisional cache when the record write fails', async () => { + const dir = await makeDir('write-failure') + const result = await harness() + result.pool.failNextWrites = 1 + await expect(result.registry.create(dir)).rejects.toThrow(/injected/) + expect(result.registry.list()).toEqual([]) + expect(await result.registry.create(dir)).toBeDefined() + }) + + it('rolls back a record when registry-order persistence fails', async () => { + const dir = await makeDir('order-write-failure') + const pool = new MemoryMediaPool() + const result = await harness({ + pool, + backend: selectiveFailureBackend(pool, { globalAt: 2 }), + }) + await expect(result.registry.create(dir)).rejects.toThrow(/marker failure/) + expect(result.registry.list()).toEqual([]) + expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(0) + }) + + it('reports both order and rollback failures while retaining the recoverable record', async () => { + const dir = await makeDir('rollback-write-failure') + const pool = new MemoryMediaPool() + const result = await harness({ + pool, + backend: selectiveFailureBackend(pool, { globalAt: 2, deleteAt: 1 }), + }) + await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError) + expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1) + }) + + it('rejects table access before the registry has started', async () => { + const dir = await makeDir('unstarted') + const registry = new WorkspaceRegistry(new Context()) + await expect(registry.create(dir)).rejects.toThrow(/not started/) + expect(() => registry.list()).toThrow(/not started/) + }) +}) + +describe('Workspace session ordering', () => { + it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => { + const dir = await makeDir('attach-order') + const result = await harness() + result.setSessions([ + header('s1', dir, 1), + header('s2', dir, 2), + header('ungrouped', dir, 3), + ]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + expect(workspace.sessionIds).toEqual(['s2', 's1']) + await workspace.attachSession(SessionId('s1')) + expect(workspace.sessionIds).toEqual(['s2', 's1']) + + const beforeTouch = result.changes.length + await Promise.all([ + result.registry.touchSession(SessionId('s1')), + result.registry.touchSession(SessionId('s1')), + ]) + expect(workspace.sessionIds).toEqual(['s1', 's2']) + expect(result.changes).toHaveLength(beforeTouch + 1) + await result.registry.touchSession(SessionId('s1')) + expect(result.changes).toHaveLength(beforeTouch + 1) + await result.registry.touchSession(SessionId('ungrouped')) + expect(result.changes).toHaveLength(beforeTouch + 1) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2']) + }) + + it('does not resurrect a session detached before its queued touch', async () => { + const dir = await makeDir('detach-touch-race') + const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] }) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + await Promise.all([ + workspace.detachSession(SessionId('s1')), + result.registry.touchSession(SessionId('s1')), + ]) + const written = result.changes.length + await workspace.detachSession(SessionId('absent')) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s2']) + }) + + it('does not reinsert a candidate absent at the durable touch slot', async () => { + const dir = await makeDir('stale-touch') + const id = WorkspaceId('00000000-0000-4000-8000-000000000030') + let durable = record(dir, ['s2', 's1']) + const table = { + update: async ( + _id: WorkspaceId, + update: (current: WorkspaceRecord) => WorkspaceRecord, + ): Promise => { + durable = { ...durable, sessionIds: [SessionId('s2')] } + durable = update(durable) + return durable + }, + } + const entity = new WorkspaceEntity({ + table: () => table as never, + sessionPath: () => dir, + readSessionHeader: async () => header('s1', dir), + rememberSessionPath: () => {}, + }, id, record(dir, ['s2', 's1'])) + await entity.touchSession(SessionId('s1')) + expect(durable.sessionIds).toEqual(['s2']) + }) + + it('validates a lazy live session without requiring it in persistence.list()', async () => { + const dir = await makeDir('live') + const result = await harness({ sessions: [], liveSessions: [header('live', dir, 1)] }) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('live')) + expect(workspace.sessionIds).toEqual(['live']) + expect(result.list).toHaveBeenCalledTimes(1) + }) + + it('rejects mismatched, missing, unresolved, non-directory, and unknown cwd facts', async () => { const dir = await makeDir('strict') const elsewhere = await makeDir('elsewhere') - const { registry } = await harness({ - sessions: [header('other-dir', elsewhere), header('no-cwd', undefined)], - }) - const workspace = await registry.create(dir) - await expect(workspace.attachSession(SessionId('other-dir'))).rejects.toThrow(/resolves to/) + const gone = await makeDir('gone') + const file = join(base, 'cwd-file') + await writeFile(file, 'file') + const result = await harness() + result.setSessions([ + header('mismatch', elsewhere), + header('no-cwd'), + header('gone', gone), + header('file', file), + ]) + await rm(gone, { recursive: true }) + const workspace = await result.registry.create(dir) + await expect(workspace.attachSession(SessionId('mismatch'))).rejects.toThrow(/resolves to/) await expect(workspace.attachSession(SessionId('no-cwd'))).rejects.toThrow(/no cwd/) + await expect(workspace.attachSession(SessionId('gone'))).rejects.toThrow(/does not resolve/) + await expect(workspace.attachSession(SessionId('file'))).rejects.toThrow(/not a directory/) await expect(workspace.attachSession(SessionId('unknown'))).rejects.toThrow(/no such session/) expect(workspace.sessionIds).toEqual([]) }) - it('rejects a cwd that no longer resolves', async () => { - const dir = await makeDir('target') - const gone = await makeDir('gone') - const { registry } = await harness({ sessions: [header('s1', gone)] }) - const workspace = await registry.create(dir) - await rm(gone, { recursive: true }) - await expect(workspace.attachSession(SessionId('s1'))).rejects.toThrow(/does not resolve/) - }) - - it('rejects every attach while session persistence is absent', async () => { - const dir = await makeDir('no-persistence') - const { registry } = await harness({ sessions: 'absent' }) - const workspace = await registry.create(dir) - await expect(workspace.attachSession(SessionId('s1'))).rejects.toThrow(/no session persistence/) - }) - - it('is idempotent on both attach and detach — a no-op never writes', async () => { - const dir = await makeDir('idem') - const { registry, changes, setSessions } = await harness({ sessions: [header('s1', dir)] }) - const workspace = await registry.create(dir) - await workspace.attachSession(SessionId('s1')) - const written = changes.length - // Re-attaching skips validation entirely: even with the session gone from - // the listing, the id already being on the account resolves without IO. - setSessions([]) - await workspace.attachSession(SessionId('s1')) - await workspace.detachSession(SessionId('absent')) - expect(changes.length).toBe(written) - }) - - it('decides membership at the write-chain slot: unawaited detach then attach re-attaches', async () => { + it('decides detach/attach membership at domain write-chain slots', async () => { const dir = await makeDir('race') - const { registry } = await harness({ sessions: [header('s1', dir)] }) - const workspace = await registry.create(dir) + const result = await harness({ sessions: [header('s1', dir)] }) + const workspace = await result.registry.create(dir) await workspace.attachSession(SessionId('s1')) - // Both fire before either lands. Snapshot-based idempotence would see - // 's1' still on the account and turn the attach into a no-op, losing it; - // chain-slot decisions replay detach → attach in order. (The attach skips - // re-validation off the same stale snapshot — the cwd fact is immutable — - // and enqueues immediately, keeping the chain order deterministic here.) const detached = workspace.detachSession(SessionId('s1')) const attached = workspace.attachSession(SessionId('s1')) await Promise.all([detached, attached]) expect(workspace.sessionIds).toEqual(['s1']) }) + + it('keeps workspace order stable while touch order survives reload', async () => { + const older = await makeDir('stable-older') + const newer = await makeDir('stable-newer') + const sessions = [ + header('old-1', older, 100), + header('old-2', older, 200), + header('new-1', newer, 300), + ] + const pool = new MemoryMediaPool() + const first = await harness({ pool, sessions }) + const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id) + const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)! + expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1']) + await first.registry.touchSession(SessionId('old-1')) + expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2']) + expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) + await first.fiber.dispose() + + const reloaded = await harness({ pool, sessions }) + expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) + expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds) + .toEqual(['old-1', 'old-2']) + }) + + it('persists activity order from session/event without any stream consumer', async () => { + const dir = await makeDir('event-touch') + const result = await harness({ sessionStore: true }) + const workspace = await result.registry.create(dir) + const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } }) + result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } }) + await workspace.attachSession(SessionId('event-first')) + await workspace.attachSession(SessionId('event-second')) + expect(workspace.sessionIds).toEqual(['event-second', 'event-first']) + + first.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) }) + expect(storedRecord(result.pool, workspace.id).sessionIds) + .toEqual(['event-first', 'event-second']) + }) + + it('contains a background activity write failure at the service listener', async () => { + const dir = await makeDir('event-touch-failure') + const result = await harness({ sessionStore: true }) + const workspace = await result.registry.create(dir) + const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } }) + result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } }) + await workspace.attachSession(SessionId('failed-first')) + await workspace.attachSession(SessionId('failed-second')) + const warn = vi.spyOn(result.ctx.logger, 'warn') + result.pool.failNextWrites = 1 + first.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) }) + expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first']) + }) }) -describe('consistency projections', () => { - it('filters accounted ids with no stored session and prunes them on the next mutation', async () => { - const dir = await makeDir('stale') +describe('header-validated membership projection', () => { + it('requires both candidate id and matching canonical cwd without re-reading on list()', async () => { + const owned = await makeDir('owned') + const elsewhere = await makeDir('projection-elsewhere') const id = WorkspaceId('00000000-0000-4000-8000-000000000001') - const pool = pooledRecord(id, record(dir, ['live', 'ghost'])) - const { registry } = await harness({ pool, sessions: [header('live', dir)] }) - const workspace = registry.get(id)! - // Rule 1: the projection hides the dead id; the durable account still holds it. - expect(workspace.sessionIds).toEqual(['live']) - expect(storedRecord(pool, id).sessionIds).toEqual(['live', 'ghost']) - // Any mutation prunes it durably. - await workspace.setTitle('renamed') - expect(storedRecord(pool, id).sessionIds).toEqual(['live']) - expect(workspace.title).toBe('renamed') + const pool = storedPool( + [[id, record(owned, ['good', 'mismatch', 'missing'])]], + { initialized: true, workspaceIds: [id] }, + ) + const result = await harness({ + pool, + sessions: [ + header('good', owned), + header('mismatch', elsewhere), + header('cwd-only', owned), + ], + }) + const workspace = result.registry.list()[0]! + expect(workspace.sessionIds).toEqual(['good']) + expect(result.registry.list()[0]!.sessionIds).toEqual(['good']) + expect(result.list).toHaveBeenCalledTimes(1) + expect(storedRecord(pool, id).sessionIds).toEqual(['good', 'mismatch', 'missing']) + + await workspace.setTitle('pruned') + expect(storedRecord(pool, id).sessionIds).toEqual(['good']) + expect(workspace.sessionIds).not.toContain('cwd-only') }) - it('serves the account unfiltered while session persistence is absent', async () => { - const dir = await makeDir('unverifiable') - const id = WorkspaceId('00000000-0000-4000-8000-000000000002') - const pool = pooledRecord(id, record(dir, ['maybe'])) - const { registry } = await harness({ pool, sessions: 'absent' }) - const workspace = registry.get(id)! - expect(workspace.sessionIds).toEqual(['maybe']) - // Mutations must not prune either: unverifiable membership is kept as-is. - await workspace.setTitle('still-unverified') - expect(storedRecord(pool, id).sessionIds).toEqual(['maybe']) + it('rejects duplicate candidate ownership, duplicate paths, and initialized order drift', async () => { + const first = await makeDir('corrupt-first') + const second = await makeDir('corrupt-second') + const firstId = '00000000-0000-4000-8000-000000000002' + const secondId = '00000000-0000-4000-8000-000000000003' + const duplicateSession = storedPool( + [[firstId, record(first, ['dup'])], [secondId, record(second, ['dup'])]], + { initialized: true, workspaceIds: [WorkspaceId(firstId), WorkspaceId(secondId)] }, + ) + await expect(harness({ pool: duplicateSession })).rejects.toThrow(/accounted/) + + const duplicatePath = storedPool( + [[firstId, record(first, [])], [secondId, record(first, [])]], + { initialized: true, workspaceIds: [WorkspaceId(firstId), WorkspaceId(secondId)] }, + ) + await expect(harness({ pool: duplicatePath })).rejects.toThrow(/claimed/) + + const orphan = storedPool( + [[firstId, record(first, [])], [secondId, record(second, [])]], + { initialized: true, workspaceIds: [WorkspaceId(firstId)] }, + ) + await expect(harness({ pool: orphan })).rejects.toThrow(/absent from registry order/) + + const repeated = storedPool( + [[firstId, record(first, [])]], + { initialized: true, workspaceIds: [WorkspaceId(firstId), WorkspaceId(firstId)] }, + ) + await expect(harness({ pool: repeated })).rejects.toThrow(/repeats workspace/) + + const missing = storedPool( + [], + { initialized: true, workspaceIds: [WorkspaceId(firstId)] }, + ) + await expect(harness({ pool: missing })).rejects.toThrow(/references missing workspace/) }) - it('prunes dead ids even when the triggering mutation is itself a no-op', async () => { - const dir = await makeDir('prune-on-noop') - const id = WorkspaceId('00000000-0000-4000-8000-000000000007') - const pool = pooledRecord(id, record(dir, ['ghost'])) - const { registry, changes } = await harness({ pool, sessions: [] }) - const workspace = registry.get(id)! - // Detaching an id that was never on the account changes nothing by - // itself, but the mutation slot still prunes the dead 'ghost' durably. - await workspace.detachSession(SessionId('never-there')) - expect(storedRecord(pool, id).sessionIds).toEqual([]) - expect(changes).toHaveLength(1) - }) - - it('rejects startup over a medium accounting one session twice', async () => { - const dirA = await makeDir('double-a') - const dirB = await makeDir('double-b') - const pool = pooledRecord('00000000-0000-4000-8000-000000000003', record(dirA, ['dup'])) - pool.media.get('workspace')!.tables.get('workspaces')! - .set('00000000-0000-4000-8000-000000000004', record(dirB, ['dup'])) - const ctx = new Context() - await ctx.plugin(Storage) - ctx.storage.backend.register('memory', new MemoryStorageBackend(pool)) - ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} })) - await expect(Promise.resolve(ctx.plugin(WorkspaceRegistry))).rejects.toThrow(/accounted/) - }) - - it('rejects startup over a medium where two records claim one path', async () => { - const dirA = await makeDir('claimed') - const pool = pooledRecord('00000000-0000-4000-8000-000000000005', record(dirA, [])) - pool.media.get('workspace')!.tables.get('workspaces')! - .set('00000000-0000-4000-8000-000000000006', record(dirA, [])) - const ctx = new Context() - await ctx.plugin(Storage) - ctx.storage.backend.register('memory', new MemoryStorageBackend(pool)) - ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} })) - await expect(Promise.resolve(ctx.plugin(WorkspaceRegistry))).rejects.toThrow(/claimed/) + it('fails list if the durable order and entity cache are externally diverged', async () => { + const dir = await makeDir('cache-diverged') + const result = await harness() + const workspace = await result.registry.create(dir) + const internals = result.registry as unknown as { entities: Map } + internals.entities.delete(workspace.id) + expect(() => result.registry.list()).toThrow(/references missing workspace/) }) }) -describe('Workspace mutation failures', () => { - it('propagates a medium write failure from a mutation and keeps the old snapshot', async () => { - const dir = await makeDir('write-fail') - const { backend, arm } = failingBackend() - const { registry } = await harness({ backend }) - const workspace = await registry.create(dir) - arm() - await expect(workspace.setTitle('lost')).rejects.toThrow(/injected/) - expect(workspace.title).toBe('write-fail') +describe('workspace mutation and status', () => { + it('keeps createdAt stable, advances updatedAt, and preserves snapshot on write failure', async () => { + const dir = await makeDir('timestamps') + const result = await harness() + const workspace = await result.registry.create(dir) + const createdAt = workspace.createdAt + expect(workspace.updatedAt).toBe(createdAt) await workspace.setTitle('kept') + expect(workspace.createdAt).toBe(createdAt) + expect(Date.parse(workspace.updatedAt)).toBeGreaterThanOrEqual(Date.parse(createdAt)) + result.pool.failNextWrites = 1 + await expect(workspace.setTitle('lost')).rejects.toThrow(/injected/) expect(workspace.title).toBe('kept') }) -}) -describe('Workspace.status', () => { - it('reports ok while the directory exists and missing-dir once it is gone, without mutating the record', async () => { + it('reports directory disappearance without mutating the workspace', async () => { const dir = await makeDir('vanishing') const { registry } = await harness() const workspace = await registry.create(dir) expect(await workspace.status()).toBe('ok') await rm(dir, { recursive: true }) expect(await workspace.status()).toBe('missing-dir') - expect(workspace.path).toBe(dir) - expect(registry.get(workspace.id)).toBe(workspace) - // The path re-materializing as a non-directory is still missing-dir. await writeFile(dir, 'now a file') expect(await workspace.status()).toBe('missing-dir') + expect(registry.get(workspace.id)).toBe(workspace) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b3b39ab93..7f19dcce27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../../packages/client/ui-trajectory + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../packages/client/ui-workspace '@deepseek-ai/dsh-compact-basic': specifier: workspace:^ version: link:../../packages/compact/compact-basic @@ -203,6 +206,15 @@ importers: '@deepseek-ai/dsh-spill-policy': specifier: workspace:^ version: link:../../packages/spill/spill-policy + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../packages/storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../packages/storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../packages/storage/storage-json '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent @@ -260,6 +272,9 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../../packages/workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../packages/workspace/workspace '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context @@ -850,6 +865,9 @@ importers: react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.31)(react@18.3.1) @@ -863,6 +881,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -992,6 +1013,36 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-workspace: + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../ui-sidebar + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/web: dependencies: '@deepseek-ai/dsh-client-modules': @@ -2228,6 +2279,9 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../ui/user-interaction + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -2238,6 +2292,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7774f2d34d..b1c6318b3b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -207,8 +207,10 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', + Domain: 'domain interface is owned by packages/storage/storage-domain/README.md', DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts', DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md', + DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md', DomainSpec: 'domain declaration contract is owned by packages/storage/storage-domain/README.md', StorageBackend: 'backend contract is owned by packages/storage/storage/src/backend.ts', StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 2ef0e385e3..9996fb9f6f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -141,16 +141,24 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Non-session storage hub', mode: 'seam', implementations: ['storage-json', 'storage-sqlite'], - consumers: ['storage-domain', 'workspace'], + consumers: ['storage-domain'], note: 'Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives.', }, + { + key: 'storageDomain', + pkg: 'storage-domain', + title: 'Domain data facility', + mode: 'core', + consumers: ['workspace'], + note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.', + }, { key: 'workspace', pkg: 'workspace', title: 'Workspace entity registry', mode: 'core', - consumers: [], - note: 'Owns WorkspaceId-branded records over the domain form; sessionIds is the single source of ownership truth. RPC and GUI consumers arrive next phase.', + consumers: ['apiproxy'], + note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.', }, { key: 'sessionQuery', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 5687e51bac..20d4195737 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 533b23e724..a449a34c4e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -113,6 +113,7 @@ "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], + "@deepseek-ai/dsh-client-ui-workspace": ["./packages/client/ui-workspace/src"], "@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"], "@deepseek-ai/dsh-client-i18n": ["./packages/client/i18n/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 7915df50ff..17f34b601f 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -34,6 +34,7 @@ { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-conversation" }, + { "path": "./packages/client/ui-workspace" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, diff --git a/vitest.web.config.ts b/vitest.web.config.ts index de220c9f12..c3de18c94a 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -1,10 +1,10 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -// Web smoke lane (GUI, gate-exempt — not part of the CI sequence yet): built -// page + real chromium, so it lives outside the unit/e2e includes. The -// real-host test self-skips without DEEPSEEK_API_KEY; the fixture test is -// keyless and deterministic. +// Web smoke lane (GUI, gate-exempt — not part of the CI sequence yet): real +// host entry points plus built-client interaction snapshots, outside the +// unit/e2e includes. Real-model cases self-skip without DEEPSEEK_API_KEY; +// fixture branches stay keyless and deterministic. try { // Node >= 21.7 native; throws when the file does not exist. process.loadEnvFile(new URL('.env', import.meta.url).pathname) @@ -18,7 +18,10 @@ export default defineConfig({ // workspace imports to source like every other lane. plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { - include: ['apps/web/tests/**/*.e2e.ts'], + include: [ + 'apps/web/tests/**/*.e2e.ts', + 'apps/web/tests/**/*.snapshot.ts', + ], // Browser boot + real-model turns are slow; files share one browser, run serial. testTimeout: 180_000, hookTimeout: 120_000, From 08ce02da2b742de256a2a40fcdac60bcbcb8855d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:15:29 +0800 Subject: [PATCH 18/22] docs(web): finalize workspace UI product flow --- ...07-25-workspace-ui-product-flow.i18n.yaml} | 4 +- .../2026-07-25-workspace-ui-product-flow.md | 117 +++++++++++++++++ ...2026-07-25-workspace-ui-product-flow.zh.md | 117 +++++++++++++++++ ...-07-25-workspace-gui-and-session-drafts.md | 121 ------------------ ...-25-workspace-gui-and-session-drafts.zh.md | 121 ------------------ packages/client/runtime/README.md | 2 +- .../runtime/src/client/sessions/manager.ts | 10 +- .../runtime/src/client/sessions/service.ts | 11 +- .../runtime/src/client/sessions/session.ts | 5 +- ...drafts.spec.ts => session-intents.spec.ts} | 0 packages/client/ui-conversation/README.md | 2 +- .../ui-conversation/src/client/service.ts | 5 +- packages/host/apiproxy/README.md | 2 +- packages/workspace/workspace/README.md | 2 +- 14 files changed, 265 insertions(+), 254 deletions(-) rename .agents/notes/{proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml => implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml} (61%) create mode 100644 .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md create mode 100644 .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md delete mode 100644 .agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md rename packages/client/runtime/tests/{session-drafts.spec.ts => session-intents.spec.ts} (100%) diff --git a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml similarity index 61% rename from .agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index 7ea5b2afb3..3295a845f3 100644 --- a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.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-25-workspace-gui-and-session-drafts.md: 9e44e092ca584a285d5e49c109063aecbbac239d -2026-07-25-workspace-gui-and-session-drafts.zh.md: 7e13e7de281711227bf446e406e0e4a18394cb4f +2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b +2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md new file mode 100644 index 0000000000..a02087235a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -0,0 +1,117 @@ +# Agent Note: Workspace UI Complete Product Flow + +Status: implemented + +English | [中文](2026-07-25-workspace-ui-product-flow.zh.md) + +## Problem + +[Domain KV Storage and the Workspace Entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) defines the persistent Workspace entity, path conventions, and ordered Session ledger, but not the Host wiring, historical-data initialization, or GUI flow. The GUI presents both Workspaces and Sessions; users must be able to type immediately after entering New Session, even when no Host Session or Host Workspace exists yet. + +Pending Workspaces, pending Sessions, retained input, and Host entity publication need clear owners and must preserve the same page identity when RPC completions and Host frames arrive in either order. Eagerly creating a Host Session for the zero state would bring a page with no input into the Host lifecycle. Historical Sessions also expose only the lightweight `SessionHeader.cwd` for grouping; initialization cannot read event bodies. + +## Decision + +### Host and persistent data + +The Host provides the following GUI wiring on the Workspace entity: + +| RPC | Behavior | +| --- | --- | +| `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | +| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | +| `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path | +| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | +| `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | + +`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. + +A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. + +The Workspace domain uses a durable marker to distinguish “never initialized” from “initialized but empty.” When the marker is absent, the Registry calls only `SessionPersistence.list()` to read header metadata; it calls neither `load` nor `inspect`, reads no history, and parses no event bodies. Valid cwd values are grouped by canonical path, and both Sessions within each group and the Workspace groups themselves are initialized in descending header `createdAt` order. Bootstrap is reentrant and writes the marker last; after the marker is written, new Sessions created without `workspaceId` are no longer adopted automatically. + +### Client object model + +`Session` and `Workspace` are frontend objects from the page Intent stage onward. + +- A frontend Session preallocates a SessionId when created and owns its Intent target and `pendingPrompt`; it remains the same Session object after Host `session.create` succeeds. +- Before materialization, a frontend Workspace has no WorkspaceId and owns its create input, phase, and error; after Host `workspace.create` succeeds, the same Workspace object adopts the returned view. +- `SessionManager` and `WorkspaceManager` own object indexes and merge Host baselines and deltas; the objects are the sole source of state for both Intents and Host views. +- `SessionsService` provides Session objects, real selection, scope, and list projections; `WorkspacesService` depends on `SessionsService` and owns the default Workspace, cross-object New Session flow, and Workspace materialization. + +A page has at most one frontend Session Intent and one accompanying Workspace Intent that exists only in the zero-Workspace state. Intents exist only on the current page and disappear on refresh; real Session selection can be restored persistently. Selecting a real Session or starting another Session Intent revokes the old Intent's eligibility for automatic sending, but does not roll back a Session already published by the Host or any accepted message. + +The Session owns the first input and drives one internal pipeline: when necessary, it attaches to a Workspace with its preallocated id, then sends `pendingPrompt`. Both attach and send failures return to the same Session. Workspace creation phase and error belong only to the Workspace object; the Session does not simulate the Workspace lifecycle. + +### User flow + +On initial entry, the application waits until both the Workspace and Session baselines are ready. It restores a real Session selection that remains valid; otherwise, it enters New Session and selects the most recent Workspace exactly once. The most recent Workspace is determined by the maximum `updatedAt` of its member Sessions, falling back to `createdAt` for an empty Workspace. This derived value chooses only the default target: it does not alter the Host Workspace order or trigger another selection after later hydration. + +When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order. + +Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. + +Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope. + +### First send and recovery + +A frontend Session's `pendingPrompt` retains its original text until the Host accepts the message. The first send advances through Workspace materialization, Session attachment, and prompt sending in order: + +1. If Workspace creation fails, the Workspace Intent retains its input and error, and the Session continues to target that object. +2. If Session creation fails before publication, the Session Intent returns to an editable state and retries with the same preallocated SessionId. +3. `workspace-attach-failed` proves that the Session has been published; the same Session object enters the real list and retains the prompt, and subsequent retries attach it. +4. If the prompt fails, the Session retains it and retries only send without recreating the Workspace or Session. +5. If the page switches to another Intent while a Session is being created, the old Session does not send automatically even if it is subsequently published; it retains its original prompt and visible error. + +Lost RPC responses, Host frames arriving before completions, and completions arriving before Host frames all converge through the preallocated SessionId and object identity. The Manager performs ordered upserts of Host views and prioritizes preserving the original object identity during local materialization, rather than creating a temporary second row with the same id. + +### Sidebar and ordering + +Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups. + +Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration. + +A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows. + +Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. + +### React and slot boundaries + +React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer. + +The Sidebar and conversation empty hero receive standardized actions through slots: `startSession`, `updateSessionPrompt`, `sendSession`, `open`, and `toggleSidebar`. The Workspace picker reuses the same component and the `createWorkspace` seam; its owner supplies only popover state, an anchor, and a selection callback. The presentation layer does not send `host/workspace-changed` directly; Host events originate only from Host mutations and the stream adapter. + +## Alternatives considered + +**Store separate page records for pending Workspaces and Sessions.** This approach must replace identities after materialization and hand off input, errors, focus, and sidebar rows; Intent state owned by the objects preserves identity continuity. + +**Let the presentation layer or root Zustand store orchestrate object lifecycles.** This approach duplicates Manager and Service responsibilities and brings domain state back into React. Runtime services provide standardized actions, while slots inject only the narrow interfaces required by presentation. + +**Immediately create a Host Session or Host persistence intent in the zero state.** A page with no input would enter the Host lifecycle and change refresh semantics; before the first send, the frontend Session retains only a page-local Intent. + +**Delay an explicit Create Workspace until the first send.** After confirmation, the sidebar would still show no real empty Workspace, conflating “create a Workspace” with “prepare a Session”; only the zero-Workspace Intent generated automatically by the system delays materialization. + +**Continuously derive Workspaces dynamically from cwd.** This cannot represent empty Workspaces, stable display names, or explicit ordering, and would automatically adopt non-Workspace callers; cwd is used only for one historical bootstrap and bidirectional membership validation. + +**Have the Client batch-reorder by time after the Session list arrives.** The initial screen would first show the Host order and then jump as a whole, and reconnecting could change positions again; the Host's persistent ledger owns ordering, while the Client merges only individual updates. + +**Add workspaceId to SessionHeader.** This would create two persistent ownership fields alongside the Workspace index and require double writes; the header retains the Session's own cwd fact, while the Workspace index owns explicit membership. + +## Verification + +- The zero state with no Workspace writes nothing to the Host and accepts input; explicit Create Workspace immediately creates and displays an empty Workspace. +- Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer. +- The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId. +- Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. +- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. +- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. +- Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. + +## Consequences + +- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward. +- Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point. +- Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract. +- Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace. +- Before its first event, a Host Session retains the existing lazy-persistence semantics; frontend Intents do not change empty-Session behavior after a Host restart. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md new file mode 100644 index 0000000000..8ccbf5b984 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -0,0 +1,117 @@ +# Agent Note: Workspace UI 完整产品动线 + +[English](2026-07-25-workspace-ui-product-flow.md) | 中文 + +Status: implemented + +## Problem + +[Domain KV storage 与 Workspace entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)定义了 Workspace 的持久实体、路径规范和有序 Session 账本,但没有定义 Host 接线、历史数据初始化或 GUI 动线。GUI 同时呈现 Workspace 和 Session;用户进入 New Session 后必须立即输入,即使此时还没有 Host Session,甚至没有 Host Workspace。 + +待创建 Workspace、待创建 Session、输入保留与 Host 实体发布必须具有明确所有者,并在 RPC completion 与 Host frame 以任意顺序到达时保持同一页面身份。若零态提前创建 Host Session,则无输入的页面状态会进入 Host 生命周期。历史 Session 又只有轻量 `SessionHeader.cwd` 可用于归组,初始化不能读取事件正文。 + +## Decision + +### Host 与持久数据 + +Host 在 Workspace entity 上提供以下 GUI 接线: + +| RPC | 行为 | +| --- | --- | +| `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | +| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | +| `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 | +| `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | +| `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | + +`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。 + +Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 + +Workspace domain 以 durable marker 区分“从未初始化”和“已初始化但为空”。marker 未设置时,Registry 只调用 `SessionPersistence.list()` 读取 header 元数据,不调用 `load`、`inspect`、history 或解析事件正文;有效 cwd 按 canonical path 分组,组内 Session 与 Workspace 组均按 header `createdAt` 降序初始化。Bootstrap 可重入,最后才写 marker;marker 写入后,绕过 `workspaceId` 的新 Session 不再被自动收编。 + +### Client 对象模型 + +`Session` 与 `Workspace` 从页面 Intent 阶段开始就是前端对象。 + +- 前端 Session 创建时预分配 SessionId,并在对象内持有 Intent target 与 `pendingPrompt`;Host `session.create` 成功后仍是同一个 Session 对象。 +- 前端 Workspace 在 materialize 前没有 WorkspaceId,并在对象内持有 create input、phase 与 error;Host `workspace.create` 成功后同一个 Workspace 对象 adopt 返回的 view。 +- `SessionManager` 与 `WorkspaceManager` 负责对象索引、Host 基线和增量合并;对象是 Intent 与 Host view 的唯一状态源。 +- `SessionsService` 提供 Session 对象、真实 selection、scope 与列表投影;`WorkspacesService` 依赖 `SessionsService`,负责默认 Workspace、跨对象 New Session 动线和 Workspace materialize。 + +页面至多有一个前端 Session Intent 和一个仅在零 Workspace 状态下配套的 Workspace Intent。Intent 只存在于当前页面,刷新后消失;真实 Session selection 可以持久恢复。选择真实 Session 或启动另一个 Session Intent 会放弃旧 Intent 的自动发送资格,但已经由 Host 发布的 Session 和已经接受的消息不会回滚。 + +Session 自己持有首条输入并驱动一条内部流水线:必要时以预分配 id attach 到 Workspace,然后发送 `pendingPrompt`。attach 与 send 的失败都落回同一 Session。Workspace 创建 phase/error 只属于 Workspace 对象,Session 不模拟 Workspace 生命周期。 + +### 用户动线 + +应用首次进入时等待 Workspace 与 Session 两份基线 ready。仍有效的真实 Session selection 被恢复;否则进入 New Session,并固定选择一次最近 Workspace。最近 Workspace 取其成员 Session 的最大 `updatedAt`,空 Workspace 回退到 `createdAt`;该派生只决定默认目标,不改变 Host Workspace 顺序,也不会在后续 hydration 时二次改选。 + +完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。 + +顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 + +Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编和显示名/目录名双输入不在本期范围。 + +### 首次发送与恢复 + +前端 Session 的 `pendingPrompt` 在 Host 接受消息前始终保留原文。首次发送按 Workspace materialize、Session attach、prompt send 顺序推进: + +1. Workspace 创建失败时,Workspace Intent 保留输入与错误,Session 仍指向该对象。 +2. Session 创建在发布前失败时,Session Intent 回到可编辑状态,以同一预分配 SessionId 重试。 +3. `workspace-attach-failed` 证明 Session 已发布;同一 Session 对象进入真实列表并保留 prompt,后续重试 attach。 +4. prompt 失败时,Session 保留 prompt 并只重试 send,不重复创建 Workspace 或 Session。 +5. Session 创建期间若页面切换到另一个 Intent,旧 Session 即使随后发布也不自动发送;它保留原 prompt 和可见错误。 + +RPC lost response、Host frame 先于 completion 和 completion 先于 Host frame 都通过预分配 SessionId 与对象身份收敛。Manager 对 Host view 做有序 upsert,本地 materialize 时优先保留原对象身份,不生成同 id 的临时第二行。 + +### Sidebar 与排序 + +Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位;Session 活跃不会移动 Workspace 组。 + +组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。 + +前端 Session Intent 只有在目标是真实 Workspace 时才作为 “New session” 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。 + +无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 + +### React 与 slot 边界 + +React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。 + +Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSession`、`updateSessionPrompt`、`sendSession`、`open` 与 `toggleSidebar`。Workspace picker 复用同一组件与 `createWorkspace` seam;owner 只提供 popover 开关、锚点和选中回调。呈现层不直接发送 `host/workspace-changed`,Host event 只由 Host mutation 与 stream adapter 产生。 + +## Alternatives considered + +**为待创建 Workspace 与 Session 保存独立页面记录。** 该方案在 materialize 后需要替换身份并转交输入、错误、焦点和 sidebar 行;对象自身的 Intent 状态可以保持身份连续。 + +**由呈现层或 root Zustand store 编排对象生命周期。** 该方案会重复 Manager/Service 的职责,并把领域状态带回 React。标准化动作由 runtime service 提供,slot 只注入呈现所需的窄接口。 + +**零态立即创建 Host Session 或 Host persistence intent。** 未输入页面会进入 Host 生命周期,并改变刷新语义;前端 Session 在首次发送前只保留 page-local Intent。 + +**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍看不到真实空 Workspace,“创建 Workspace”与“准备 Session”语义混合;只有系统自动产生的零 Workspace Intent 延迟 materialize。 + +**持续按 cwd 动态派生 Workspace。** 该方案无法表达空 Workspace、稳定显示名和显式顺序,也会自动收编非 Workspace 调用方;cwd 只用于一次历史 bootstrap 与成员双向校验。 + +**Client 在 Session list 到达后按时间批量重排。** 首屏会先展示 Host 顺序再整体跳动,重连也可能改变位置;排序由 Host 持久账本拥有,Client 只合并单项更新。 + +**在 SessionHeader 增加 workspaceId。** 它会与 Workspace 索引形成两个持久归属字段并要求双写;header 保留 Session 自身 cwd 事实,Workspace 索引负责显式归属。 + +## Verification + +- 完全无 Workspace 的零态不写 Host 且允许输入;显式 Create Workspace 立即创建并显示空 Workspace。 +- 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。 +- 首发按 Workspace、Session、prompt 顺序推进,各成功阶段不回滚,输入在 prompt 接受前不丢失,创建重试使用同一 SessionId。 +- Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 +- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 +- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 +- UI 与 Host 两层拒绝同名 Workspace;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 + +## Consequences + +- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。 +- 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。 +- 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 契约。 +- 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。 +- Host Session 在首个事件前仍遵循现有懒持久化语义;前端 Intent 不改变 Host 重启后的空 Session 行为。 diff --git a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md deleted file mode 100644 index 9e44e092ca..0000000000 --- a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md +++ /dev/null @@ -1,121 +0,0 @@ -# Agent Note: Workspace GUI and session drafts - -Status: proposed - -English | [中文](2026-07-25-workspace-gui-and-session-drafts.zh.md) - -## Problem - -[Domain KV storage and the Workspace entity](../architecture/2026-07-24-domain-kv-storage-and-workspace.md) define the persistent Workspace entity, path conventions, and ordered session ledger, but do not define Host wiring, historical-data initialization, or GUI flows. The GUI displays Workspaces and Sessions together, and users must be able to type immediately after entering the New Session page, even when no real Session or even real Workspace exists yet. - -Using one intent to represent both a pending Workspace and a pending Session would make explicit Create Workspace actions, the automatic empty state, sidebar draft rows, and first-send failures share an ambiguous state. Creating a Host Session in advance to support the empty state would instead produce an empty Session with no user input, no persisted data before its first event, and no survival across restarts. Existing historical Sessions also expose only `SessionHeader.cwd`, so the system needs to build an initial Workspace view without reading event bodies. - -## Proposal - -### State and ownership - -Workspace and Session are two real Host objects; WorkspaceDraft and SessionDraft are two page-local Client states: - -- A `Workspace` can be empty, persists durably, and always appears in the sidebar; -- A `Session` is a real object already created by the Host; -- A `WorkspaceDraft` exists only for the automatic empty state when the system has no Workspace at all and does not appear in the sidebar; -- A `SessionDraft` represents a pending Session and holds its target Workspace or WorkspaceDraft, preallocated SessionId, composer content, and send phase. - -At most one SessionDraft exists on a page. A draft under a real Workspace appears in the sidebar as “New session”; neither a WorkspaceDraft nor its SessionDraft appears there. A new draft replaces the old one; selecting a real Session or refreshing the page discards any unmaterialized draft and uncommitted input. Real Workspaces, real Sessions, and messages already accepted by the Host are unaffected. - -The Client represents the current page with the discriminated union `ConversationStage = Session | SessionDraft` instead of simulating a draft by clearing current and storing an intent elsewhere. Workspace, Session, and ConversationStage are separate object layers; only a real Session selection can be persisted. - -### End-to-end Host and wire flow - -The Host exposes the following GUI wiring over the existing Workspace entity: - -| RPC | Behavior | -| --- | --- | -| `workspace.list` | Returns real Workspaces in a stable order and filters out session ids that fail header validation | -| `workspace.create({ name })` | Creates a directory at `workspaceRoot/name` and a Workspace when the name is available; duplicate-name requests fail | -| `workspace.create({ path })` | Adopts an existing directory without creating directories for arbitrary input paths | -| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a real Session with an optional preallocated id, and attaches it | -| `session.create({ cwd })` | Remains available to non-GUI callers and creates an Ungrouped Session | - -`workspaceRoot` is an independent Host configuration that falls back to the Host cwd when unset; it is unrelated to the `storageRoot` that stores Workspace domain data. The Host stream pushes incremental Workspace and Session updates, while reconnection uses `workspace.list` and `session.list` as its two baselines. - -The GUI preallocates a SessionId in SessionDraft but creates no Host intent before the first send. On the first send, the Client passes that id to `session.create`; the Host uses the same id to create both the real Session and its persistence create-intent. Retrying the same id with the same cwd is idempotent; an existing id with a different cwd fails loudly. This lets a lost response or partial attach failure reconcile to the same Session instead of creating a duplicate. - -A Workspace's `sessionIds` is an ordered candidate index. A Session is a member only when its id is present in the index and its canonicalized `SessionHeader.cwd` equals the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id with a missing header or mismatched cwd does not enter the projection. A Session appearing in two Workspace indexes is corrupt state and fails loudly. - -### One-time historical initialization - -The Workspace domain uses a durable marker to distinguish “never initialized” from “initialized but empty.” When the marker is absent, WorkspaceRegistry performs a reentrant bootstrap once: - -1. Call `SessionPersistence.list()` exactly once; JSONL reads only the first header line, SQLite reads only session metadata rows, and the bootstrap must not call `load`, `inspect`, history APIs, or parse event bodies. -2. Ignore headers with no cwd, a nonexistent path, a path that is not a directory, or a failed realpath lookup; these Sessions remain Ungrouped. -3. Group by canonical cwd, sort each group by header `createdAt` in descending order before writing `sessionIds`, and order Workspace groups stably by each group's maximum `createdAt`, also descending. -4. After a crash, reentry reuses Workspaces already written for the same canonical path and merges missing ids; write the marker last, after all records are durable. - -After the marker is written, the system no longer creates Workspaces or backfills their ledgers automatically from cwd. Subsequent call paths that omit `workspaceId` remain Ungrouped; this is a compatibility path, not a second source that continuously derives Workspaces. - -### User flows - -On initial entry, the Client waits until both Workspace and Session baselines are ready; it restores a still-existing real Session selection when possible and otherwise enters the New Session flow. When the user explicitly enters New Session, the Client does not restore the old selection: it selects the most recent Workspace and creates a SessionDraft. The most recent Workspace is determined by the maximum `updatedAt` among its validated member Sessions, with an empty Workspace falling back to `createdAt`. This value only chooses the default target for New Session; it neither changes the sidebar Workspace order nor triggers a second selection after the Session list arrives. - -When no Workspace exists at all, the page creates a WorkspaceDraft named `workspace` and its SessionDraft. Neither is written to the Host, but the composer always remains editable. The top-level New Session action reenters this empty-state selection flow without calling `session.create` immediately. - -The plus button in the Sidebar Workspace section header and the Workspace creation entry in the composer reuse the same picker and modal: - -- Select an existing Workspace: create only a SessionDraft targeting that Workspace; -- Use an existing folder: call `workspace.create({ path })`, then create a SessionDraft under it after success; -- Create new: use one input as both the directory name and title; the UI disables confirmation when an existing Workspace has that title, and the Host rejects duplicate-name requests caused by bypassing the UI or concurrent creation; after success, create a SessionDraft under it. - -Explicit Create Workspace creates a real Workspace as soon as the user confirms and immediately displays it in the sidebar; the empty Workspace remains even if the user never sends a message. The inline plus button on a Workspace row creates only a SessionDraft under that group: it neither creates another Workspace nor immediately creates a Host Session. - -Sending the first message performs these steps in order: create the Workspace when necessary, create the Session with the preallocated id, hand the stage and composer buffer off to the real Session, and call `session.prompt`. The Client clears the input only after the Host accepts the prompt. A Workspace remains if failure occurs after it is created; a real Session remains selected if failure occurs after it is published; a prompt failure retains the original input and retries the same Session. - -### Sidebar and ordering - -Workspace groups use the persistent stable order returned by the Host. Bootstrap establishes the historical order once, and explicitly created Workspaces go first; Session activity never moves Workspace groups. - -Within a group, Sessions render strictly in `Workspace.sessionIds` order. Historical Sessions are initialized from the header `createdAt`, and new Sessions go first; whenever a Session's `updatedAt` advances afterward, the Host moves only that id to the front of its Workspace and persists the change. The Client does not batch-sort by `updatedAt` after Session list hydration, so the page never displays the bootstrap order and then jumps as a whole. - -SessionDraft is a presentation-layer row appended without writing to `sessionIds`. When a real Workspace has a SessionDraft, the sidebar's page-derived session count temporarily increases by one; once the real Session with the same id appears, it must not be counted twice, and refreshing removes both the draft and its temporary count. `host/session-added` and `host/workspace-changed` may arrive in either order; the Client merges them by the preallocated SessionId and removes the draft once the real row can be located, without ever briefly showing two rows with the same id. - -### Client and UI boundaries - -A dedicated WorkspacesService manages the Workspace list phase, incremental upserts, reconnect refresh, creation, and recent-Workspace derivation. SessionsService manages only the real Session list, Session scope, history, running state, and real selection. A page-local conversation coordinator manages ConversationStage, SessionDraft, materialization phase, errors, and composer-buffer handoff. - -The existing sidebar layout, row styles, EmptyHero, composer styles, Menu/Modal/Tooltip, portal and slot infrastructure, and `ui-workspace` component skeleton can remain. The Workspace/Session state boundary, empty state, creation actions, first-send state machine, historical initialization, and component props need to be rewritten. The Sidebar and conversation-empty entry points must use the same Workspace data and creation actions; only their anchor direction, open state, and selection callback may differ. - -This phase uses English UI text and does not provide Workspace rename/delete, Session delete, cross-Workspace moves, drag ordering, manual adoption from Ungrouped, multiple SessionDrafts, draft restoration after refresh, or separate display-name and directory-name inputs. - -## Alternatives considered - -**Continue deriving Workspaces dynamically from cwd.** This cannot represent empty Workspaces, stable display names, or explicit order, and it would automatically adopt non-GUI Sessions. Derivation is allowed only for the one-time historical bootstrap; ownership must subsequently be written explicitly to the index. - -**Use one WorkspaceIntent to represent both WorkspaceDraft and SessionDraft.** Their visibility, persistence, and materialization timing differ. Combining them prevents explicit Create Workspace from taking effect immediately and prevents the sidebar from distinguishing a hidden WorkspaceDraft from a draft row under a real Workspace. - -**Create a Host Session or Host persistence intent immediately for the empty state.** A Session with no input would enter the Host lifecycle, while refresh semantics would conflict with a page-local draft. Only a Client SessionDraft exists before the first send. - -**Delay explicit Create Workspace until the first send.** The sidebar would still have no real empty Workspace after user confirmation, conflating “Create Workspace” with “prepare Session.” Only the automatic no-Workspace empty state allows delayed creation. - -**Batch-reorder on the Client by updatedAt after the Session list arrives.** The page would first show the bootstrap `createdAt` order and then jump as a whole, while reconnection could not restore the same order. The Host moves only the corresponding id when an individual Session becomes active. - -**Add workspaceId to SessionHeader.** This would create two persistent ownership fields alongside the Workspace index and require dual writes. The header retains the Session's own cwd fact, the Workspace index owns explicit membership, and reads validate both directions. - -## Acceptance criteria - -- Explicit Create Workspace immediately creates and displays an empty Workspace; the automatic empty state with no Workspace writes nothing to the Host and remains editable. -- New Session, selecting an existing Workspace, both Workspace creation methods, and the inline plus button on a Workspace row each produce the single SessionDraft and follow the sidebar visibility rules. -- The first send materializes Workspace, Session, and prompt in that order; successful stages are not rolled back, input is retained until the prompt is accepted, and retries use the same SessionId. -- The Workspace list performs one reentrant bootstrap using headers only; tests prove it never reads event bodies and that an initialized empty registry does not repeat the bootstrap after restart. -- Membership reads validate both the index and header cwd; cwd-only Sessions, invalid historical cwd values, and failed attaches become Ungrouped. -- Initial rendering waits for both baselines to be ready; Session activity does not move Workspace groups, arrival of the Session list does not trigger a full reorder, and activity in one Session moves only that Session to the front and preserves the order across reconnection. -- Workspace and Session updates arriving in either order never create duplicate Session rows; every first-send failure stage can recover to the same preallocated id. -- Create new rejects duplicate Workspace names at both the UI and Host layers; a SessionDraft under a real Workspace temporarily counts toward the sidebar total, and neither materialization nor refresh leaves a duplicate count. -- Real runnable keyless snapshots cover the empty state, explicit creation, successful first send, failed first send, refresh, and Ungrouped; package-level tests cover bootstrap, bidirectional membership validation, ordering, and idempotency. - -## Risks - -- Header-only bootstrap has no historical activity time and can initialize order only from `createdAt`; it does not batch-correct from the Session list afterward, and only new activity in individual Sessions progressively changes in-group order. -- Historical Sessions with a missing cwd or a path that cannot be resolved by realpath remain Ungrouped; this phase has no manual adoption entry point. -- Refreshing the page discards WorkspaceDraft, SessionDraft, and input not yet accepted by the Host; this is the page-local contract. -- Before its first event, a Host Session still has only a live object and a persistence create-intent; restarting the Host loses that empty Session. This design does not change the existing lazy-persistence semantics by persisting page drafts. -- Explicit Create Workspace persists immediately, so leaving without sending a message still leaves an empty Workspace; this is the cost of making the operation take effect immediately. diff --git a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md b/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md deleted file mode 100644 index 7e13e7de28..0000000000 --- a/.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.zh.md +++ /dev/null @@ -1,121 +0,0 @@ -# Agent Note: Workspace GUI and session drafts - -[English](2026-07-25-workspace-gui-and-session-drafts.md) | 中文 - -Status: proposed - -## Problem - -[Domain KV storage 与 Workspace entity](../architecture/2026-07-24-domain-kv-storage-and-workspace.md)定义了 Workspace 的持久实体、路径规范和有序 session 账本,但没有定义 Host 接线、历史数据初始化或 GUI 动线。GUI 同时显示 Workspace 和 Session,并且用户进入 New Session 页面后必须立即输入,即使此时还没有真实 Session,甚至没有真实 Workspace。 - -若用一个 intent 同时表示待创建 Workspace 和待创建 Session,显式 Create Workspace、自动零态、sidebar draft 行和首发失败会共享一组含混状态。若为了解决零态而提前创建 Host Session,又会产生没有用户输入、首个事件前不落盘且重启即消失的空 Session。现有历史 Session 还只有 `SessionHeader.cwd`,需要在不读取事件正文的前提下建立一次初始 Workspace 视图。 - -## Proposal - -### 状态与所有权 - -Workspace 与 Session 是两个真实 Host 对象;WorkspaceDraft 与 SessionDraft 是两个 page-local Client 状态: - -- `Workspace` 可以为空,持久存在并始终显示在 sidebar; -- `Session` 是已经由 Host 创建的真实对象; -- `WorkspaceDraft` 只用于“系统完全没有 Workspace”的自动零态,不显示在 sidebar; -- `SessionDraft` 表示一个待创建 Session,持有目标 Workspace 或 WorkspaceDraft、预分配 SessionId、composer 内容和发送 phase。 - -页面至多存在一个 SessionDraft。真实 Workspace 下的 draft 在 sidebar 显示为 “New session”;WorkspaceDraft 及其 SessionDraft 都不显示。新 draft 替换旧 draft;选择真实 Session 或刷新页面会丢弃未物化 draft 和未提交输入。真实 Workspace、真实 Session 和已经接受的消息不受影响。 - -Client 用判别联合 `ConversationStage = Session | SessionDraft` 表达当前页面,不再用“清空 current 再另存 intent”模拟草稿。Workspace、Session 和 ConversationStage 各有独立对象层;只有真实 Session selection 可以持久化。 - -### Host 与 wire 全链路 - -Host 在现有 Workspace entity 上提供以下 GUI 接线: - -| RPC | 行为 | -| --- | --- | -| `workspace.list` | 返回稳定有序的真实 Workspace,并过滤未通过 header 校验的 session id | -| `workspace.create({ name })` | 名称未被占用时在 `workspaceRoot/name` 建目录并创建 Workspace;重名请求失败 | -| `workspace.create({ path })` | 收编已经存在的目录,不为任意输入路径建目录 | -| `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建真实 Session 并 attach | -| `session.create({ cwd })` | 保留给非 GUI 调用方,创建 Ungrouped Session | - -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 和 Session 增量,重连以 `workspace.list` 与 `session.list` 两份基线为准。 - -GUI 在 SessionDraft 中预分配 SessionId,但首次发送前不创建任何 Host intent。首次发送时,Client 才把该 id 传给 `session.create`;Host 用同一 id 创建真实 Session 和 persistence create-intent。相同 id、相同 cwd 的重试幂等;id 已存在但 cwd 不同则 fail loud。这样响应丢失和 attach 部分失败都能对账到同一个 Session,而不是重复创建。 - -Workspace 的 `sessionIds` 是有序候选索引。读取成员必须同时满足 id 在索引中且 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 仍是 Ungrouped,索引命中但 header 缺失或 cwd 不匹配的 id 不进入投影。同一 Session 出现在两个 Workspace 索引中属于损坏状态并 fail loud。 - -### 一次性历史初始化 - -Workspace domain 用 durable marker 区分“从未初始化”和“已初始化但为空”。marker 未设置时,WorkspaceRegistry 执行一次可重入 bootstrap: - -1. 只调用一次 `SessionPersistence.list()`;JSONL 只读 header 首行,SQLite 只读 session 元数据行,禁止调用 `load`、`inspect`、history 或解析事件正文。 -2. 忽略无 cwd、目录不存在、非目录或 realpath 失败的 header;这些 Session 留在 Ungrouped。 -3. 按 canonical cwd 分组,组内按 header `createdAt` 降序写入 `sessionIds`,Workspace 组按各组最大 `createdAt` 降序写入稳定顺序。 -4. 崩溃重入时按 canonical path 复用已经写入的 Workspace 并合并缺失 id;全部记录 durable 后最后写 marker。 - -marker 写入后不再按 cwd 自动建 Workspace 或补账。后续绕过 `workspaceId` 的调用链保持 Ungrouped;这是一条兼容路径,不是持续派生 Workspace 的第二写源。 - -### 用户动线 - -应用首次进入时,Client 等待 Workspace 与 Session 两份基线都 ready;仍存在的真实 Session selection 可以恢复,否则进入 New Session 流程。用户显式进入 New Session 时不恢复旧 selection,而是选择最近 Workspace 并创建 SessionDraft。最近 Workspace 取其已验证成员 Session 的最大 `updatedAt`;空 Workspace 回退到 `createdAt`。该值只决定 New Session 的默认目标,不改变 sidebar 的 Workspace 顺序,也不会在 Session list 到达后触发二次选择。 - -完全没有 Workspace 时,页面创建名为 `workspace` 的 WorkspaceDraft 和其 SessionDraft。它们不写 Host,但 composer 始终可输入。顶部 New Session 重新进入该零态选择流程,不立即调用 `session.create`。 - -Sidebar Workspace 区头加号和 composer 的 Workspace 创建入口复用同一个 picker 与 modal: - -- 选择已有 Workspace:只创建指向该 Workspace 的 SessionDraft; -- Use an existing folder:调用 `workspace.create({ path })`,成功后创建其下的 SessionDraft; -- Create new:用一个输入同时作为目录名和 title;UI 对已有 Workspace title 禁止确认,Host 拒绝绕过 UI 或并发产生的重名请求;成功后创建其下的 SessionDraft。 - -显式 Create Workspace 在用户确认时立即产生真实 Workspace,并立即显示在 sidebar;即使用户不发送消息,也会留下空 Workspace。Workspace 行内加号只创建该组下的 SessionDraft,不创建另一个 Workspace,也不立即创建 Host Session。 - -发送首条消息时依次执行:必要时创建 Workspace、以预分配 id 创建 Session、把 stage 和 composer buffer 转交给真实 Session、调用 `session.prompt`。只有 Host 接受 prompt 后才清空输入。Workspace 已创建后失败则保留 Workspace;Session 已发布后失败则保留并聚焦真实 Session;prompt 失败则保留原输入并重试同一 Session。 - -### Sidebar 与排序 - -Workspace 组使用 Host 返回的持久稳定顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放到首位;Session 活跃不会移动 Workspace 组。 - -组内严格按 `Workspace.sessionIds` 渲染。历史 Session 以 header `createdAt` 初始化,新 Session 放到首位;此后某个 Session 的 `updatedAt` 前进时,Host 只把该 id 移到所属 Workspace 的首位并持久化。Client 不在 Session list hydration 后按 `updatedAt` 批量排序,因此页面不会先显示 bootstrap 顺序再整体跳动。 - -SessionDraft 是渲染层附加行,不写入 `sessionIds`。真实 Workspace 下存在 SessionDraft 时,sidebar 的页面派生 session 数量临时加一;同 id 的真实 Session 出现后不能重复计数,刷新后 draft 与临时计数一起消失。`host/session-added` 与 `host/workspace-changed` 可能以任意顺序到达;Client 按预分配 SessionId 合并,并在真实行可定位后移除 draft,不能短暂显示两个同 id 行。 - -### Client 与 UI 边界 - -独立 WorkspacesService 管理 Workspace list phase、增量 upsert、重连 refresh、create 和最近 Workspace 派生;SessionsService 只管理真实 Session list、Session scope、history、running 状态与真实 selection;page-local conversation coordinator 管理 ConversationStage、SessionDraft、物化 phase、错误和 composer buffer 转交。 - -现有 sidebar 布局、行样式、EmptyHero、composer 样式、Menu/Modal/Tooltip、portal、slot 基建和 `ui-workspace` 组件骨架可以保留。需要重写的是 Workspace/Session 状态边界、零态、创建动作、首发状态机、历史初始化和组件 props。Sidebar 与 conversation empty 两个入口必须使用同一 Workspace 数据和创建动作,只允许锚点方向、开关状态与选中回调不同。 - -本期 UI 使用英文,不提供 Workspace rename/delete、Session delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编、多 SessionDraft、draft 刷新恢复或显示名与目录名的双输入。 - -## Alternatives considered - -**继续按 cwd 动态派生 Workspace。** 该方案无法表示空 Workspace、稳定显示名或显式顺序,也会把非 GUI Session 自动收编;只允许一次历史 bootstrap,之后归属必须显式写入索引。 - -**用一个 WorkspaceIntent 同时表示 WorkspaceDraft 与 SessionDraft。** 两者的显示、持久化和物化时点不同;合并后显式 Create Workspace 无法立即生效,sidebar 也无法区分隐藏 WorkspaceDraft 与真实 Workspace 下的 draft 行。 - -**零态立即创建 Host Session 或 Host persistence intent。** 未输入的 Session 会进入 Host 生命周期,刷新语义与 page-local 草稿冲突;首次发送前只保留 Client SessionDraft。 - -**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍没有真实空 Workspace,“Create Workspace”与“准备 Session”语义混合;只有自动无 Workspace 零态允许延迟。 - -**Client 在 Session list 到达后按 updatedAt 批量重排。** 页面会先展示 bootstrap 的 `createdAt` 顺序再整体跳动,重连也无法恢复同一顺序;Host 只在单个 Session 活跃时前移对应 id。 - -**在 SessionHeader 中增加 workspaceId。** 它会与 Workspace 索引形成两个持久归属字段并要求双写;header 保留 session 自身的 cwd 事实,Workspace 索引负责显式归属,读取时双向校验。 - -## Acceptance criteria - -- 显式 Create Workspace 立即创建并显示空 Workspace;完全无 Workspace 的自动零态不写 Host 且允许输入。 -- New Session、选择已有 Workspace、两种 Workspace 创建方式和 Workspace 行内加号都产生唯一的 SessionDraft,并遵守 sidebar 可见性规则。 -- 首发按 Workspace、Session、prompt 顺序物化;各已成功阶段不回滚,输入在 prompt 接受前不丢失,重复创建使用同一 SessionId。 -- Workspace list 只用 header 完成一次可重入 bootstrap;测试证明不读取事件正文,initialized 的空 registry 重启也不重复执行。 -- 归属读取同时校验索引与 header cwd;cwd-only Session、无效历史 cwd 和 attach 失败进入 Ungrouped。 -- 首次渲染等待两份基线 ready;Workspace 组不因 Session 活跃移动,Session list 到达不触发整体重排,单个活跃 Session 只前移自身并在重连后保持顺序。 -- Workspace 与 Session 增量以任意顺序到达都不会产生重复 Session 行;首发各失败阶段都能恢复到同一个预分配 id。 -- Create new 在 UI 与 Host 两层拒绝重名 Workspace;真实 Workspace 下的 SessionDraft 临时计入 sidebar 数量,物化与刷新都不会留下重复计数。 -- 真实 runnable keyless snapshot 覆盖零态、显式创建、首发成功、首发失败、刷新和 Ungrouped;包级测试覆盖 bootstrap、双向归属、排序与幂等。 - -## Risks - -- Header-only bootstrap 没有历史活跃时间,只能用 `createdAt` 初始化顺序;初始化后不按 Session list 批量修正,只有新的单项活跃逐步改变组内顺序。 -- 历史 cwd 缺失或无法 realpath 的 Session 会留在 Ungrouped;本期没有手动收编入口。 -- 页面刷新会丢弃 WorkspaceDraft、SessionDraft 和尚未接受的输入;这是 page-local 契约。 -- Host Session 在首个事件前仍只有 live 对象和 persistence create-intent,Host 重启会丢失该空 Session;本设计不通过持久化页面 draft 改变现有懒持久化语义。 -- 显式 Create Workspace 立即落盘,因此用户不发送就离开也会留下空 Workspace;这是该操作真实生效的代价。 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index f1e13f007a..6b697cbed9 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-runtime -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService owns Workspace objects, list/actions, page-local Workspace Intent state, and default-target derivation. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, page-local Workspace Intent state, default-target derivation, and the cross-object New Session flow. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4. ## Workspace and Session lists diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 8d9dd4d958..907fc961f6 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -149,12 +149,18 @@ export class SessionManager { return session } - /** @returns the active frontend Session, if one remains selected. */ + /** + * Resolve the active frontend Session Intent. + * @returns the active frontend Session, if one remains selected. + */ getIntent(): Session | undefined { return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId) } - /** @param text - exact controlled-input value for the active frontend Session. */ + /** + * Update the retained prompt of the active frontend Session. + * @param text - exact controlled-input value for the active frontend Session. + */ updateIntent(text: string): void { this.getIntent()?.updatePendingPrompt(text) } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index f77717d7f0..845292a481 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -209,17 +209,24 @@ export class SessionsService { * Start or retarget the sole client-local Session intent. * @param target - resolved real or frontend-only Workspace target. * @param prompt - optional prompt retained across retargeting. + * @returns the frontend Session object that owns the Intent. */ startIntent(target: SessionIntentTarget, prompt = ''): Session { return this.manager.startIntent(target, prompt) } - /** @returns the active frontend Session object, if one exists. */ + /** + * Resolve the active frontend Session Intent. + * @returns the active frontend Session object, if one exists. + */ intent(): Session | undefined { return this.manager.getIntent() } - /** @param text - exact controlled-input value for the current Session Intent. */ + /** + * Update the retained prompt of the active frontend Session. + * @param text - exact controlled-input value for the current Session Intent. + */ updateIntent(text: string): void { this.manager.updateIntent(text) } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 4173318fc2..396d0aa798 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -141,7 +141,10 @@ export class Session implements ObservableSnapshot { return result } - /** @param text - exact controlled value of this Session's retained prompt. */ + /** + * Update this Session's retained prompt while it remains editable. + * @param text - exact controlled value of this Session's retained prompt. + */ updatePendingPrompt(text: string): void { const pending = this.pendingPrompt if (pending === null || pending.phase === 'sending') return diff --git a/packages/client/runtime/tests/session-drafts.spec.ts b/packages/client/runtime/tests/session-intents.spec.ts similarity index 100% rename from packages/client/runtime/tests/session-drafts.spec.ts rename to packages/client/runtime/tests/session-intents.spec.ts diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0b7e4ef247..0711adcccb 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts and publishes the two intents. The Session object keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. +The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 6e1ca2b70e..5ea5ea96ee 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -49,7 +49,10 @@ export class ConversationService extends Service { await this.scopedSession('loadOlder').loadOlder() } - /** Update the scoped Session's retained pending prompt. */ + /** + * Update the scoped Session's retained pending prompt. + * @param text - exact controlled-input value to retain. + */ updatePendingPrompt(text: string): void { this.scopedSession('updatePendingPrompt').updatePendingPrompt(text) } diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index cc8b5a7237..58cab1f2cc 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,7 +10,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. Session drafts are client-only and have no wire method. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. Frontend Workspace and Session Intents are client-only and have no wire method. ## Carrier layer (`/client` + root) diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index a687910319..b333d90ad0 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -2,7 +2,7 @@ Workspace entity registry (`ctx.workspace`) for the DeepSeek Harness: durable workspace records, stable workspace order, and a newest-first candidate session index stored through the domain data form. Consumers see the `Workspace` interface; the entity implementation stays package-private. -The entity/storage rationale lives in the [domain Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md); header-only bootstrap and GUI ordering live in the [Workspace GUI Agent Note](../../../.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md). +The entity/storage rationale lives in the [domain Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md); header-only bootstrap and GUI ordering live in the [Workspace UI product-flow Agent Note](../../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md). ## Shape From 468c64a078472b7758962f0f38d428b4ae763f39 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:19:36 +0800 Subject: [PATCH 19/22] fix(web): keep composer add action attachment-only --- .../client/ui-conversation/src/client/skeleton/EmptyHero.tsx | 4 ---- .../client/ui-conversation/src/client/skeleton/EmptyState.tsx | 1 - packages/client/ui-conversation/tests/skeleton.spec.tsx | 2 ++ 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index b8badba406..941729f9a0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -73,7 +73,6 @@ export interface EmptyHeroProps { status?: string onDraftChange: (text: string) => void onSend: (mode: 'queue' | 'steer') => void - onAdd?: () => void /** Overlay content after the stack (EmptyState's modals). */ children?: ReactNode } @@ -92,7 +91,6 @@ export function EmptyHero({ status, onDraftChange, onSend, - onAdd, children, }: EmptyHeroProps) { // Stable filter id so multiple hero mounts do not collide in the DOM. @@ -140,8 +138,6 @@ export function EmptyHero({ placeholder={placeholder ?? 'Describe what you want to build'} onDraftChange={onDraftChange} onSend={onSend} - {...(onAdd === undefined ? {} : { onAdd })} - addLabel="Create workspace" /* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */ onStop={() => {}} /> diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index dd8865efb8..c363d094a6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -72,7 +72,6 @@ export function EmptyState({ error={error} onDraftChange={updateSessionPrompt} onSend={() => { sendSession() }} - onAdd={() => { setPickerOpen(true) }} /> ) } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index fc596ec821..e4a8aa2696 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -72,6 +72,8 @@ describe('EmptyState', () => { prompt: 'draft', phase: 'ready', }) expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('workspace') + fireEvent.click(b.view.getByRole('button', { name: 'Add attachment' })) + expect((b.pickerOwner() as { open: boolean }).open).toBe(false) fireEvent.change(b.view.getByPlaceholderText('Describe what you want to build'), { target: { value: 'build it' } }) expect(b.updateSessionPrompt).toHaveBeenCalledWith('build it') fireEvent.click(b.view.getByRole('button', { name: 'Send message' })) From c06cb2deec6dafb19bd6c1c8bd9121cf84011e10 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:30:23 +0800 Subject: [PATCH 20/22] test(web): stabilize workspace snapshots --- apps/web/tests/session-title.snapshot.ts | 5 +++-- apps/web/tests/workspace-flow.snapshot.ts | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index 5fbb23814b..c1616bb724 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -93,8 +93,9 @@ it('projects initial and revised durable titles through the built nine-plugin fi unmount = () => { entry.dispose() } }) - const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 }) - const projectRow = projectLabel.closest('[role="treeitem"]') + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const projectCount = await within(tree).findByText('4 sessions') + const projectRow = projectCount.closest('[role="treeitem"]') if (projectRow === null) throw new Error('fixture project row missing') fireEvent.click(projectRow) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 6ebc3bb7c0..78ac843a64 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -108,7 +108,7 @@ function visibleText(element: Element): string { return (element.textContent ?? '').replace(/\s+/g, ' ').trim() } -/** The labelled chip and its adjacent plus button intentionally share a label. */ +/** Identify the interactive Workspace chip by its menu contract. */ function workspaceChip(): HTMLElement { const chip = screen.getAllByRole('button', { name: 'Choose workspace' }) .find(element => element.getAttribute('aria-haspopup') === 'menu') @@ -116,12 +116,18 @@ function workspaceChip(): HTMLElement { return chip } +/** Wait for the runtime-owned controlled input to echo a browser edit. */ +async function setComposerText(composer: HTMLElement, value: string): Promise { + fireEvent.change(composer, { target: { value } }) + await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) }) +} + it('starts a writable page-local draft without inventing a sidebar Workspace', async () => { boot('?fixture=empty') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const tree = screen.getByRole('tree', { name: 'Sessions' }) - fireEvent.change(composer, { target: { value: 'keep this local' } }) + await setComposerText(composer, 'keep this local') expect({ headline: visibleText(screen.getByText("Let's start building")), @@ -182,7 +188,7 @@ it('drops the page-local draft on refresh while retaining real Workspaces and Se const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const tree = screen.getByRole('tree', { name: 'Sessions' }) - fireEvent.change(composer, { target: { value: 'discard this page-local draft' } }) + await setComposerText(composer, 'discard this page-local draft') const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]') if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh') @@ -226,7 +232,7 @@ it('keeps a published Session with only cwd membership evidence in Ungrouped', a boot('?fixture&fixtureAttach=fail') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - fireEvent.change(composer, { target: { value: 'keep this cwd-only session' } }) + await setComposerText(composer, 'keep this cwd-only session') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const tree = screen.getByRole('tree', { name: 'Sessions' }) @@ -261,7 +267,7 @@ it('materializes the automatic Workspace and Session on the first successful sen boot('?fixture=empty') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - fireEvent.change(composer, { target: { value: 'build a lighthouse' } }) + await setComposerText(composer, 'build a lighthouse') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const tree = screen.getByRole('tree', { name: 'Sessions' }) @@ -290,7 +296,7 @@ it('keeps the published Workspace, Session, and unsent prompt after rejection', boot('?fixture=empty&fixturePrompt=reject') const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) - fireEvent.change(composer, { target: { value: 'do not lose this' } }) + await setComposerText(composer, 'do not lose this') fireEvent.click(screen.getByRole('button', { name: 'Send message' })) const alert = await screen.findByRole('alert', {}, { timeout: 10_000 }) From 1a9c1596b8dd1ddaa0e0b199b77bc6f4c7c3bf88 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:38:32 +0800 Subject: [PATCH 21/22] test(web): cover workspace UI branches --- .../client/connection/tests/fixture.spec.ts | 36 ++++ .../client/ui-sidebar/tests/rows.spec.tsx | 76 ++++++++ .../ui-sidebar/tests/sidebar-root.spec.tsx | 183 +++++++++++++++++- packages/client/ui-sidebar/tests/tree.spec.ts | 86 +++++++- .../tests/workspace-picker.spec.tsx | 46 ++++- .../storage-domain/tests/domain.spec.ts | 21 +- 6 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 packages/client/ui-sidebar/tests/rows.spec.tsx diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index d860c69234..16fa4b4ed6 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -377,6 +377,42 @@ describe('createFixtureApi', () => { }) }) + it('attaches an existing ungrouped Session to a matching Workspace', async () => { + const api = createFixtureApi() + const sessionId = sid('fx-existing-ungrouped') + await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({ + result: { ok: true, value: { sessionId } }, + }) + + await expect(api.sessions.create(req({ + sessionId, + workspaceId: 'fx-ws-fixture' as WorkspaceId, + }))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } }) + + const workspaces = await api.workspace.list(req({})) + if (!workspaces.result.ok) throw new Error('workspace list failed') + expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId) + }) + + it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => { + const api = createFixtureApi() + const listed = await api.sessions.list(req({})) + if (!listed.result.ok) throw new Error('session list failed') + const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha')) + if (existing === undefined) throw new Error('fixture Session missing') + delete existing.cwd + + const conflict = await api.sessions.create(req({ sessionId: existing.sessionId })) + expect(conflict.result).toEqual({ + ok: false, + error: { + code: 'session-conflict', + message: `session ${existing.sessionId} already uses no cwd`, + details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' }, + }, + }) + }) + it('publishes an ungrouped Session when Workspace attachment fails', async () => { const api = createFixtureApi({ failWorkspaceAttach: true }) const sessionId = sid('fx-partial') diff --git a/packages/client/ui-sidebar/tests/rows.spec.tsx b/packages/client/ui-sidebar/tests/rows.spec.tsx new file mode 100644 index 0000000000..468ce550d9 --- /dev/null +++ b/packages/client/ui-sidebar/tests/rows.spec.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx' +import type { GroupNode, SessionNode } from '../src/client/tree.ts' + +afterEach(cleanup) + +const sid = (id: string) => id as SessionId +const wid = (id: string) => id as WorkspaceId + +describe('sidebar rows', () => { + it('renders an active Workspace and keeps its create action separate from toggling', () => { + const onToggle = vi.fn() + const onCreate = vi.fn() + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', + sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [], + } + render() + + expect(screen.getByText('1 session')).toBeTruthy() + expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: 'New session in Project' })) + expect(onCreate).toHaveBeenCalledOnce() + expect(onToggle).not.toHaveBeenCalled() + fireEvent.click(screen.getByText('Project')) + expect(onToggle).toHaveBeenCalledOnce() + }) + + it('renders the frontend Intent placeholder as selected', () => { + render() + expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true') + }) + + it('renders and operates selected, running, recursive Session nodes', () => { + const child: SessionNode = { + id: sid('child'), title: 'Child', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + const parent: SessionNode = { + id: sid('parent'), title: 'Parent', children: [child], hasChildren: true, + expanded: true, running: true, updatedAt: 0, + } + const onOpen = vi.fn() + const onToggle = vi.fn() + const view = render( + , + ) + + const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')! + const childRow = screen.getByText('Child').closest('[role="treeitem"]')! + expect(parentRow.getAttribute('aria-selected')).toBe('true') + expect(parentRow.getAttribute('aria-expanded')).toBe('true') + expect(childRow.getAttribute('aria-selected')).toBe('false') + expect(childRow.hasAttribute('aria-expanded')).toBe(false) + + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) + expect(onToggle).toHaveBeenCalledWith(parent.id) + expect(onOpen).not.toHaveBeenCalled() + fireEvent.click(parentRow) + fireEvent.click(childRow) + expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]]) + + view.rerender( + , + ) + expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy() + expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false') + expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px') + }) +}) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index bae2ed69ee..26adafd793 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -1,13 +1,16 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const hook = (snapshot: T) => (selector: (state: T) => S): S => selector(snapshot) @@ -41,6 +44,43 @@ function mount(sessionState: SessionListState = sessions) { return { view, startSession, open, pickerOwner: () => pickerOwner } } +function mountSidebar({ + sessionState = sessions, + workspaceState = workspaces, + collapsed = false, + width = 300, +}: { + sessionState?: SessionListState + workspaceState?: WorkspaceListState + collapsed?: boolean + width?: number +} = {}) { + const startSession = vi.fn() + const open = vi.fn() + const toggleSidebar = vi.fn() + let pickerOwner: unknown + let current = { sessionState, workspaceState, collapsed, width } + const root = () => ( + { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + /> + ) + const view = render(root()) + return { + startSession, + open, + toggleSidebar, + pickerOwner: () => pickerOwner, + rerender(next: Partial) { + current = { ...current, ...next } + view.rerender(root()) + }, + } +} + describe('SidebarRoot', () => { it('renders real Workspaces from useWorkspaces and routes New Session', () => { const b = mount() @@ -79,4 +119,143 @@ describe('SidebarRoot', () => { fireEvent.click(screen.getByText('First session')) expect(b.open).toHaveBeenCalledWith(sid('s1')) }) + + it('opens, selects, dismisses, and toggles the group-by menu', () => { + mount() + const button = screen.getByRole('button', { name: 'Group by' }) + + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + + fireEvent.click(button) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + + fireEvent.click(button) + fireEvent.click(button) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('routes every Workspace picker close path', () => { + const b = mount() + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + const owner = b.pickerOwner() as { open: boolean; onClose(): void } + expect(owner.open).toBe(true) + act(() => { owner.onClose() }) + expect((b.pickerOwner() as { open: boolean }).open).toBe(false) + }) + + it('focuses, filters, and clears search while distinguishing both empty states', () => { + mount() + const input = screen.getByPlaceholderText('Search name, keywords...') + fireEvent.click(input.parentElement!) + expect(document.activeElement).toBe(input) + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + + fireEvent.change(input, { target: { value: 'missing' } }) + expect(screen.getByText('No matches')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + expect(screen.queryByText('No matches')).toBeNull() + + cleanup() + const emptySessions = listState() + const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined } + mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces }) + expect(screen.getByText('No sessions yet')).toBeTruthy() + }) + + it('toggles Workspace and nested Session expansion in both directions', () => { + const parent = sid('parent') + const child = sid('child') + const nestedSessions: SessionListState = { + ...sessions, + ids: [parent, child], + byId: { + [parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 }, + [child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent }, + }, + } + const nestedWorkspace: WorkspaceListState = { + ...workspaces, + items: [{ ...workspace, sessionIds: [parent, child] }], + } + mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace }) + + fireEvent.click(screen.getByText('Project')) + fireEvent.click(screen.getByRole('button', { name: 'Expand' })) + expect(screen.getByText('Child')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) + expect(screen.queryByText('Child')).toBeNull() + fireEvent.click(screen.getByText('Project')) + expect(screen.queryByText('Parent')).toBeNull() + }) + + it('does not start a Session from an Ungrouped row create action', () => { + const loose = sid('loose') + const looseSessions: SessionListState = { + ...listState(), + ids: [loose], + byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } }, + current: loose, + } + const b = mountSidebar({ + sessionState: looseSessions, + workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined }, + }) + fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) + expect(b.startSession).not.toHaveBeenCalled() + }) + + it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => { + const b = mountSidebar() + fireEvent.click(screen.getByText('Project')) + const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] } + b.rerender({ + sessionState: { ...sessions, current: sid('s1') }, + workspaceState: { ...workspaces, items: [other, workspace] }, + }) + expect(screen.getByText('First session')).toBeTruthy() + + b.rerender({ + sessionState: { + ...sessions, + current: sid('draft'), + intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' }, + }, + }) + expect(screen.getByText('Project')).toBeTruthy() + }) + + it('renders the static collapsed rail and expands rail search into focused input', () => { + vi.useFakeTimers() + const b = mountSidebar({ collapsed: true }) + expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() + expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' })) + expect(b.toggleSidebar).toHaveBeenCalledOnce() + + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(b.toggleSidebar).toHaveBeenCalledTimes(2) + b.rerender({ collapsed: false }) + const input = screen.getByPlaceholderText('Search name, keywords...') + act(() => { vi.advanceTimersByTime(300) }) + expect(document.activeElement).toBe(input) + }) + + it('keeps wide content during live collapse, then settles to the rail', () => { + vi.useFakeTimers() + const b = mountSidebar({ width: 320 }) + fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) + expect(b.toggleSidebar).toHaveBeenCalledOnce() + b.rerender({ collapsed: true, width: 56 }) + expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy() + act(() => { vi.advanceTimersByTime(150) }) + expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() + }) }) + +function listState(): SessionListState { + return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined } +} diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-sidebar/tests/tree.spec.ts index 76d68db4ee..d114d43dea 100644 --- a/packages/client/ui-sidebar/tests/tree.spec.ts +++ b/packages/client/ui-sidebar/tests/tree.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import { deriveGroups, formatRelativeTime, UNGROUPED_KEY } from '../src/client/tree.ts' +import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId @@ -59,6 +59,90 @@ describe('deriveGroups', () => { expect(groups[0]!.intentHere).toBe(false) expect(groups[0]!.sessionCount).toBe(2) }) + + it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => { + const parent = summary('parent', 1) + const oldChild = { ...summary('old-child', 10), parentId: parent.id } + const newChild = { ...summary('new-child', 20), parentId: parent.id } + const tieB = { ...summary('tie-b', 20), parentId: parent.id } + const tieA = { ...summary('tie-a', 20), parentId: parent.id } + const self = { ...summary('self', 2), parentId: sid('self') } + const orphan = { ...summary('orphan', 3), parentId: sid('missing') } + const cycleA = { ...summary('cycle-a', 4), parentId: sid('cycle-b') } + const cycleB = { ...summary('cycle-b', 5), parentId: sid('cycle-a') } + const groups = deriveGroups( + list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), + [], + { expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' }, + ) + + expect(groups).toHaveLength(1) + expect(groups[0]!.sessions.map(node => node.id)).toEqual([ + sid('orphan'), sid('self'), parent.id, sid('cycle-a'), + ]) + expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([ + newChild.id, tieA.id, tieB.id, oldChild.id, + ]) + + // Equal timestamps use ids as a deterministic tiebreak in either input order. + expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]! + .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')]) + }) + + it('tolerates Workspace membership arriving before its Session summary', () => { + const partial: SessionListState = { + ...list(), + ids: [sid('present')], + byId: { [sid('present')]: summary('present', 1) }, + } + const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project'])) + expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) + }) + + it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => { + const root = { ...summary('root', 1), displayTitle: 'Ancestor' } + const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id } + const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id } + const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') } + const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') } + const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') } + const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') } + const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB) + const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle')) + + expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([ + root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id, + ]) + + const labelOnly = deriveGroups( + list(summary('hidden', 1)), + [workspace('label-hit', ['hidden']), workspace('other', [])], + view([], 'label'), + ) + expect(labelOnly).toEqual([ + expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }), + ]) + }) + + it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => { + const owned = summary('owned', 1) + const loose = summary('loose', 2) + const ws = workspace('project', ['owned']) + const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view()) + expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true) + const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view()) + expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true) + }) +}) + +describe('projectLabel', () => { + it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => { + expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL) + expect(projectLabel('')).toBe(UNGROUPED_LABEL) + expect(projectLabel('/projects/demo/')).toBe('demo') + expect(projectLabel('C:\\projects\\demo\\')).toBe('demo') + expect(projectLabel('/')).toBe('/') + }) }) describe('formatRelativeTime', () => { diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 32d5be145b..523e869961 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -79,12 +79,23 @@ describe('WorkspacePicker', () => { const createWorkspace = vi.fn(async () => created) const b = mount([], createWorkspace) chooseCreateItem('Use an existing folder') - fireEvent.change(screen.getByLabelText('Existing folder path'), { target: { value: ' /tmp/project ' } }) - fireEvent.click(screen.getByRole('button', { name: 'Use folder' })) + const input = screen.getByLabelText('Existing folder path') + fireEvent.keyDown(input, { key: 'ArrowRight' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(createWorkspace).not.toHaveBeenCalled() + fireEvent.change(input, { target: { value: ' /tmp/project ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) }) + it('closes a creation modal when the user cancels', () => { + mount([]) + chooseCreateItem('Create a new workspace') + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + it('blocks a create-new name already present in the Workspace list', () => { const b = mount([workspace('alpha', 'Alpha')]) chooseCreateItem('Create a new workspace') @@ -98,16 +109,43 @@ describe('WorkspacePicker', () => { it('exposes creation phase and error text while retaining the modal for retry', async () => { let reject!: (reason: unknown) => void const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) - const b = mount([], vi.fn(() => pending)) + const createWorkspace = vi.fn(() => pending) + const b = mount([], createWorkspace) chooseCreateItem('Create a new workspace') - fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } }) + const input = screen.getByLabelText('New workspace name') + fireEvent.keyDown(input, { key: 'ArrowRight' }) + fireEvent.change(input, { target: { value: 'broken' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) expect(screen.getByRole('status').textContent).toBe('Creating workspace…') + fireEvent.keyDown(input, { key: 'Enter' }) + expect(createWorkspace).toHaveBeenCalledTimes(1) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.getByRole('dialog')).toBeTruthy() await act(async () => { reject(new Error('disk unavailable')); await pending.catch(() => {}) }) expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: disk unavailable') expect(b.view.getByRole('dialog')).toBeTruthy() }) + it('reports non-Error creation failures', async () => { + const b = mount([], vi.fn(async () => { throw 'permission denied' })) + chooseCreateItem('Create a new workspace') + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') + }) + expect(b.onPick).not.toHaveBeenCalled() + }) + + it('waits to show its menu until an optional anchor is available', () => { + render( + , + ) + expect(screen.queryByRole('menu')).toBeNull() + }) + it('shows list loading through a stable status surface', () => { const state: WorkspaceListState = { ...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false, diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 761c1d7c1d..4a083b3f78 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { z } from 'zod' import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' -import { DomainFacility, defineDomain, domainTable } from '../src/index.ts' +import { apply, DomainFacility, defineDomain, domainTable } from '../src/index.ts' import type { Config } from '../src/index.ts' import type { DomainChanged } from '../src/events.ts' import { MemoryMediaPool, MemoryStorageBackend } from './helpers/memory-backend.ts' @@ -151,6 +151,25 @@ describe('DomainFacility.open', () => { }) describe('plugin apply', () => { + it('uses only the default backend when routes are omitted', async () => { + const ctx = new Context() + await ctx.plugin(Storage) + const backend = new MemoryStorageBackend() + ctx.storage.backend.register('memory', backend) + const disposeBackend = ctx.provide(storageBackendServiceKey('memory'), backend) + + const fiber = await ctx.plugin({ + name: 'storage-domain-routeless-test', + inject: ['storage'], + apply: (domainCtx: Context) => apply(domainCtx, { backend: 'memory' }), + }) + await vi.waitFor(() => { expect(ctx.storageDomain).toBeInstanceOf(DomainFacility) }) + + disposeBackend() + await vi.waitFor(() => { expect(ctx.get('storageDomain')).toBeUndefined() }) + await fiber.dispose() + }) + it('waits for routed backends, then mounts one lifecycle-bound service and form', async () => { const ctx = new Context() await ctx.plugin(Storage) From 1f0d555f60af9149010f1ff1204010911fb036f6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:45:50 +0800 Subject: [PATCH 22/22] docs(missions): record workspace GUI closeout lessons --- missions/readme.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 missions/readme.md diff --git a/missions/readme.md b/missions/readme.md new file mode 100644 index 0000000000..66167719ad --- /dev/null +++ b/missions/readme.md @@ -0,0 +1,36 @@ +# Workspace GUI 收尾备忘 + +## 产品改动 + +- 用户要求“去掉功能”时,先拆开视觉入口、可访问性语义和响应行为分别确认。本次 composer 加号保留原样和 `Add attachment` 标签,只在组合层停止传入 Workspace 回调;不要删除按钮、改样式或把它禁用。 +- 临时交互不应上浮到 React 呈现层。Session/Workspace Intent、首次消息保留和 materialize 重试归 runtime 对象与 service;组件只接收标准 action、hooks 和纯呈现状态。 +- RFC、测试名称和 PR 描述只写最终产品语义,不保留 `reconcilePublishedDraft`、`pendingCwd` 等已经撤销的中间方案。 + +## Snapshot 与测试定位 + +- `apps/web/tests/**/*.snapshot.ts` 验证 built application,需用 `DSH_EXAMPLE_MODE=lib`,并确认相关 `lib/` 已由当前源码构建;普通 source-mode Vitest 通过不能替代它。 +- 对 runtime 管理的受控输入执行 `fireEvent.change` 后,必须 `waitFor` 输入值回显再点击发送,否则发送可能读取旧的空 prompt。 +- 页面中 Workspace 与 Session 可以同名,禁止用无作用域的 `findByText` 定位。先用 `within` 锁定 Sessions tree、计数或对应 group,再找目标行。 +- 新 push 后先看 assembled snapshot 是否真正跑过;本地 focused snapshot 通过后仍以 `gh pr checks` 的 artifact job 为准。 + +## Coverage 收口 + +- 测试筛选和 coverage 筛选是两件事。用 owning tests 配合逐个 `--coverage.include=''`,先拿到真实未覆盖行和分支,不要直接反复跑全仓 coverage。 +- 多个 coverage 进程并发时必须给不同的 `--coverage.reportsDirectory`,否则报告目录互相覆盖。各 worker 完成后再跑一次合并后的精确 coverage,确认共享 worktree 的改动组合起来仍为 100%。 +- 全仓 coverage 若先被无关测试超时打断,不能把它当作目标文件的结论;先用精确 include 修本分支缺口,再让 CI exhaustive coverage 验证整体。 +- Coverage 测试仍要描述行为,不写“为了覆盖某分支”的注释。不可达分支才使用已有规范允许的 `v8 ignore`,可达分支补真实行为测试。 + +## 并发与提交 + +- Coverage 适合按不相交写区并发:例如 Sidebar tests、Workspace picker tests、connection/storage tests。派工时明确“只改 tests、不改 src、不 commit、不得回滚他人改动”。 +- 不直接信任各 worker 的单独结果;主会话审查 diff、运行合并后的 focused coverage、清理生成报告,再统一 commit。 +- 推送前按 `dsh-pre-push-checks` 选择最小充分验证,不重复已经通过的检查;正常 push 让 pre-push typecheck 运行,并核对本地 HEAD 与远端 ref 一致。 +- 生成的 `.coverage/` 只属于本地诊断。环境拒绝 `rm -rf` 时,依次使用 `find .coverage -type f -delete` 和 `find .coverage -depth -type d -empty -delete`;不要让报告进入 commit。 + +## GitHub 与 CI + +- GitHub 操作统一走 `gh`,并从 git 配置注入代理:`proxy="$(git config --get http.https://github.com.proxy)"; https_proxy="$proxy" http_proxy="$proxy" GH_PAGER=cat ~/.local/bin/gh ...`。不要改用网页。 +- 每次 push 都会产生一轮新 checks;旧轮次的失败不能代表当前 HEAD。先确认 run 对应当前提交,再拉失败日志。 +- `gh run watch` 只监视一个 workflow。最终必须用 `gh pr checks` 汇总 CI、e2e、sandbox 和 Windows 等独立 workflow;偶发平台失败先等当前 HEAD 重跑结果,不预先修改无关代码。 +- PR base 和 description 在最终 push 后再次用 `gh pr edit --base ... --body-file ...` 同步。PR 描述应包含最终产品动线、架构边界和实际运行过的验证,不写仍待执行的承诺。 +- Review thread 用 GraphQL/`gh api` 检查 `isResolved` 和已有回复,避免对已经解决的旧实现评论重复修复。