From 207aab9d8d5c9281444265f699576d99ec031a1c Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 18:59:26 +0800 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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